Remove outdated edge gateway object classes, add new agent implementation
Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
This commit is contained in:
@@ -0,0 +1,223 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"snapshot_of": "backend-php",
|
||||||
|
"projects": {
|
||||||
|
"backend-php": {
|
||||||
|
"display_name": "Copenhagen Truck Wash API",
|
||||||
|
"relative_root": ".",
|
||||||
|
"commands": {
|
||||||
|
"setup": {
|
||||||
|
"default": "./scripts/setup.sh",
|
||||||
|
"win32": "powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1"
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"default": "docker compose up -d traefik redis mysql-debug php1 caddy"
|
||||||
|
},
|
||||||
|
"test_unit": {
|
||||||
|
"default": "docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\""
|
||||||
|
},
|
||||||
|
"test_e2e": {
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"test_full": {
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"default": "docker compose logs -f --tail=200 php1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"codex_environment": {
|
||||||
|
"name": "api",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"name": "Start API",
|
||||||
|
"icon": "run",
|
||||||
|
"command_id": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Stop API",
|
||||||
|
"icon": "run",
|
||||||
|
"literal_command": {
|
||||||
|
"default": "docker compose down"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PHP logs",
|
||||||
|
"icon": "debug",
|
||||||
|
"command_id": "debug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PHP unit tests",
|
||||||
|
"icon": "test",
|
||||||
|
"command_id": "test_unit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI workflow check",
|
||||||
|
"icon": "debug",
|
||||||
|
"literal_command": {
|
||||||
|
"default": "node scripts/sync-ai-workflow.mjs --check"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"generated_content": {
|
||||||
|
"aiassistant_tests_lines": [
|
||||||
|
"# Backend PHP Testing Rules",
|
||||||
|
"",
|
||||||
|
"These rules apply to `services/nginx/app/tests` and any backend change that needs verification.",
|
||||||
|
"",
|
||||||
|
"1. Add or update tests for every new feature, bug fix, API contract change, or search or authentication workflow change.",
|
||||||
|
"2. Run backend verification in the `php1` container. Do not use host-side PHP for the supported workflow.",
|
||||||
|
"3. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\"` for unit coverage.",
|
||||||
|
"4. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:integration\"` when code depends on Redis, MySQL, or environment-backed configuration.",
|
||||||
|
"5. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:api\"` when route behavior, envelopes, or request parsing changes.",
|
||||||
|
"6. Keep tests deterministic: no live third-party calls, no shared Redis keys, no broad database cleanup, and no sleeps unless time behavior is the thing under test.",
|
||||||
|
"7. Prefer narrow fixtures, explicit cleanup, and behavior-level assertions over implementation checks.",
|
||||||
|
"8. When public routes, schemas, or permissions change, update `openapi.yaml` together with the tests.",
|
||||||
|
"9. Run the narrowest relevant suite first, then the broader suite that matches the risk before you finish the task.",
|
||||||
|
"",
|
||||||
|
"Canonical workflow reference: `.ai-workflow/workflow.md`."
|
||||||
|
],
|
||||||
|
"aiassistant_routes_lines": [
|
||||||
|
"# Backend Route Rules",
|
||||||
|
"",
|
||||||
|
"These rules apply to files under `services/nginx/app/routes` and the classes they call.",
|
||||||
|
"",
|
||||||
|
"1. Keep route handlers thin: validate input, enforce permissions, call domain code, and write the response.",
|
||||||
|
"2. Default to protected endpoints. Use the existing authentication and permission helpers instead of ad hoc access checks.",
|
||||||
|
"3. Add or update backend tests in the `php1` container whenever route behavior changes.",
|
||||||
|
"4. Update `openapi.yaml` whenever paths, parameters, request bodies, response envelopes, or permissions change.",
|
||||||
|
"5. Prefer deterministic route tests and avoid live external integrations in route coverage.",
|
||||||
|
"6. Use concise API errors and keep sensitive implementation details out of the response body.",
|
||||||
|
"7. When a change touches department, order, or subuser authorization, cover both the allow path and the deny path.",
|
||||||
|
"",
|
||||||
|
"Canonical workflow reference: `.ai-workflow/workflow.md`."
|
||||||
|
],
|
||||||
|
"junie_lines": [
|
||||||
|
"# Copenhagen Truck Wash API Development Guidelines",
|
||||||
|
"",
|
||||||
|
"This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the backend repository.",
|
||||||
|
"",
|
||||||
|
"## Build And Run",
|
||||||
|
"",
|
||||||
|
"- Project root: `services/nginx/app` is the effective PHP application root.",
|
||||||
|
"- Setup: use `./scripts/setup.sh` on POSIX or `powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1` on Windows.",
|
||||||
|
"- Start local API stack: `docker compose up -d traefik redis mysql-debug php1 caddy`.",
|
||||||
|
"- Tail logs with `docker compose logs -f --tail=200 php1`.",
|
||||||
|
"",
|
||||||
|
"## Testing",
|
||||||
|
"",
|
||||||
|
"- Supported backend validation runs in `php1`.",
|
||||||
|
"- Unit tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\"`.",
|
||||||
|
"- Integration tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:integration\"`.",
|
||||||
|
"- API tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:api\"`.",
|
||||||
|
"- Prefer the narrowest suite that proves the change, then run the broader suite that matches the risk.",
|
||||||
|
"",
|
||||||
|
"## Workflow Notes",
|
||||||
|
"",
|
||||||
|
"- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.",
|
||||||
|
"- Route and schema changes require matching updates to `openapi.yaml`.",
|
||||||
|
"- Runtime OpenAI product behavior is out of scope for this workflow bundle unless a task explicitly changes product code.",
|
||||||
|
"",
|
||||||
|
"Canonical workflow reference: `.ai-workflow/workflow.md`."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assistants": {
|
||||||
|
"codex": {
|
||||||
|
"description": "Codex environment files and actions."
|
||||||
|
},
|
||||||
|
"aiassistant": {
|
||||||
|
"description": "Project-specific AI Assistant rules."
|
||||||
|
},
|
||||||
|
"junie": {
|
||||||
|
"description": "Project-specific Junie guidance."
|
||||||
|
},
|
||||||
|
"copilot": {
|
||||||
|
"description": "Standardized Copilot task dispatch workflow."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"setup": {
|
||||||
|
"description": "Install dependencies and prepare the supported local environment."
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"description": "Start the primary local development entrypoint for the project."
|
||||||
|
},
|
||||||
|
"test_unit": {
|
||||||
|
"description": "Run the project's narrow unit-style verification command."
|
||||||
|
},
|
||||||
|
"test_e2e": {
|
||||||
|
"description": "Run the project's targeted browser or end-to-end verification command."
|
||||||
|
},
|
||||||
|
"test_full": {
|
||||||
|
"description": "Run the broader high-confidence verification command when the project defines one."
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"description": "Run the supported debug entrypoint."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"generated_outputs": [
|
||||||
|
{
|
||||||
|
"id": "backend_codex_environment",
|
||||||
|
"template": "codex_project_environment",
|
||||||
|
"project": "backend-php",
|
||||||
|
"path": ".codex/environments/environment.toml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend_aiassistant_tests",
|
||||||
|
"template": "aiassistant_backend_tests_rule",
|
||||||
|
"project": "backend-php",
|
||||||
|
"path": ".aiassistant/rules/Creating and maintaining tests.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend_aiassistant_routes",
|
||||||
|
"template": "aiassistant_backend_routes_rule",
|
||||||
|
"project": "backend-php",
|
||||||
|
"path": ".aiassistant/rules/Creating and securing routes.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend_junie_guidelines",
|
||||||
|
"template": "junie_backend_guidelines",
|
||||||
|
"project": "backend-php",
|
||||||
|
"path": ".junie/guidelines.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend_copilot_workflow",
|
||||||
|
"template": "copilot_dispatcher_workflow",
|
||||||
|
"project": "backend-php",
|
||||||
|
"path": ".github/workflows/copilot.yml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync_targets": {
|
||||||
|
"backend-php": {
|
||||||
|
"source_root": ".",
|
||||||
|
"destination": "C:\\Users\\2jepp\\PhpstormProjects\\api",
|
||||||
|
"supported_metadata_dirs": [
|
||||||
|
".codex",
|
||||||
|
".aiassistant",
|
||||||
|
".junie",
|
||||||
|
".github",
|
||||||
|
".ai-workflow",
|
||||||
|
"scripts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"copilot": {
|
||||||
|
"workflow_name": "Copilot Task Dispatcher",
|
||||||
|
"input_description": "The task description for Copilot",
|
||||||
|
"issue_label": "copilot-task",
|
||||||
|
"title_prefix": "Copilot Task:",
|
||||||
|
"body_lines": [
|
||||||
|
"Assigned to Copilot by @{{ACTOR}}.",
|
||||||
|
"",
|
||||||
|
"Task",
|
||||||
|
"{{TASK}}",
|
||||||
|
"",
|
||||||
|
"AI workflow notes",
|
||||||
|
"- Generated assistant metadata is synchronized from the canonical AI workflow bundle.",
|
||||||
|
"- Run `node scripts/sync-ai-workflow.mjs --check` if assistant metadata changed."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<!-- AUTOGENERATED SNAPSHOT for backend-php: refresh from the canonical workspace with `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||||
|
|
||||||
|
# Developer AI Workflow
|
||||||
|
|
||||||
|
This directory is the canonical source of truth for the repository's developer-facing AI workflow.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Keep Codex, AI Assistant, Junie, and Copilot aligned from one maintained source.
|
||||||
|
- Make assistant metadata deterministic so the generated files can be rewritten safely and checked in CI.
|
||||||
|
- Preserve the current mirror-repo workflow for `backend-php` and `front-end-vue`.
|
||||||
|
- Keep all changes in this workflow scoped to developer tooling and documentation. Runtime OpenAI features stay out of scope.
|
||||||
|
|
||||||
|
## Supported Assistants
|
||||||
|
|
||||||
|
- `Codex`: local environments and actions under `.codex/environments`.
|
||||||
|
- `AI Assistant`: generated guidance under `.aiassistant/rules`.
|
||||||
|
- `Junie`: generated project guidance under `.junie/guidelines.md`.
|
||||||
|
- `Copilot`: standardized issue-dispatch workflow content under `.github/workflows/copilot.yml`.
|
||||||
|
|
||||||
|
## Global Rules
|
||||||
|
|
||||||
|
1. Change only `.ai-workflow/workflow.md` and `.ai-workflow/manifest.json` when updating the developer AI workflow.
|
||||||
|
2. Regenerate all derived files with `node scripts/sync-ai-workflow.mjs --write`.
|
||||||
|
3. Validate drift with `node scripts/sync-ai-workflow.mjs --check`.
|
||||||
|
4. Generated assistant files are not hand-edited.
|
||||||
|
5. Unsupported surfaces stay unsupported until they have a real owner and a real config.
|
||||||
|
6. `front-end-vue/.ai/mcp/mcp.json` is intentionally unsupported and should not be recreated until there is an actual MCP integration to maintain.
|
||||||
|
|
||||||
|
## Command Matrix
|
||||||
|
|
||||||
|
### `backend-php`
|
||||||
|
|
||||||
|
- Setup: use the existing setup scripts in `backend-php/scripts`.
|
||||||
|
- Run: start the API stack with `traefik`, `redis`, `mysql-debug`, `php1`, and `caddy`.
|
||||||
|
- Debug: tail `php1` logs.
|
||||||
|
- PHP verification: always run backend validation in the `php1` container.
|
||||||
|
- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`.
|
||||||
|
- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`.
|
||||||
|
- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`.
|
||||||
|
|
||||||
|
### `front-end-vue`
|
||||||
|
|
||||||
|
- Setup: `npm ci` and `npx playwright install`.
|
||||||
|
- Run: `npm run dev`.
|
||||||
|
- Unit tests: `npm run test:unit` with Vitest.
|
||||||
|
- Browser smoke: `npm run test:e2e:smoke`.
|
||||||
|
- Browser full suite: `npm run test:e2e:ci`.
|
||||||
|
- Debug: `npx playwright test --debug`.
|
||||||
|
- Browser validation is Playwright-first. WebdriverIO and Appium are not part of the supported workflow.
|
||||||
|
|
||||||
|
### `automation`
|
||||||
|
|
||||||
|
- Setup: `npm ci` and `npx playwright install`.
|
||||||
|
- Run: `npx playwright test --ui`.
|
||||||
|
- Browser tests: `npx playwright test`.
|
||||||
|
- Debug: `npx playwright test --debug`.
|
||||||
|
|
||||||
|
## Mirror Repos And Sync
|
||||||
|
|
||||||
|
- `backend-php` mirrors to `C:\Users\2jepp\PhpstormProjects\api`.
|
||||||
|
- `front-end-vue` mirrors to `C:\Users\2jepp\WebstormProjects\pleno-vue`.
|
||||||
|
- Supported metadata directories that must stay mirrored are `.codex`, `.aiassistant`, `.junie`, `.github`, generated `.ai-workflow`, and `scripts/sync-ai-workflow.mjs`.
|
||||||
|
- Cache and build directories remain excluded from the watcher.
|
||||||
|
- The mirror repos receive generated snapshots of `.ai-workflow` and `scripts/sync-ai-workflow.mjs` so their local CI can run `--check` without depending on the combined workspace root.
|
||||||
|
|
||||||
|
## Generated Outputs
|
||||||
|
|
||||||
|
- Root Codex environment for combined backend and frontend entrypoints.
|
||||||
|
- Backend and frontend Codex environments.
|
||||||
|
- Backend and frontend AI Assistant guidance.
|
||||||
|
- Backend and frontend Junie guidance.
|
||||||
|
- Backend Copilot dispatcher workflow.
|
||||||
|
- Backend and frontend snapshot copies of `.ai-workflow` plus `scripts/sync-ai-workflow.mjs` for mirrored repositories.
|
||||||
@@ -2,261 +2,20 @@
|
|||||||
apply: always
|
apply: always
|
||||||
---
|
---
|
||||||
|
|
||||||
### Creating and maintaining tests — Rules (Project‑specific)
|
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||||
|
|
||||||
These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository.
|
# Backend PHP Testing Rules
|
||||||
|
|
||||||
1. Scope and philosophy
|
These rules apply to `services/nginx/app/tests` and any backend change that needs verification.
|
||||||
- Prefer fast, deterministic CLI scripts over framework‑based tests.
|
|
||||||
- Keep unit‑style tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised.
|
|
||||||
- Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`.
|
|
||||||
|
|
||||||
2. Location and naming
|
1. Add or update tests for every new feature, bug fix, API contract change, or search or authentication workflow change.
|
||||||
- Place tests in `services/nginx/app/tests/<domain>/YourTest.php` where `<domain>` reflects the feature area (e.g., `subusers`, `orders`, `redis`).
|
2. Run backend verification in the `php1` container. Do not use host-side PHP for the supported workflow.
|
||||||
- File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`).
|
3. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"` for unit coverage.
|
||||||
- Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it.
|
4. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"` when code depends on Redis, MySQL, or environment-backed configuration.
|
||||||
|
5. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"` when route behavior, envelopes, or request parsing changes.
|
||||||
3. Execution modes
|
6. Keep tests deterministic: no live third-party calls, no shared Redis keys, no broad database cleanup, and no sleeps unless time behavior is the thing under test.
|
||||||
- Unit‑style (preferred): run on host with PHP CLI.
|
7. Prefer narrow fixtures, explicit cleanup, and behavior-level assertions over implementation checks.
|
||||||
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
8. When public routes, schemas, or permissions change, update `openapi.yaml` together with the tests.
|
||||||
- Integration‑style (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`.
|
9. Run the narrowest relevant suite first, then the broader suite that matches the risk before you finish the task.
|
||||||
- Start minimal stack: `docker compose up -d traefik redis php1 caddy`
|
|
||||||
- Command: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
|
|
||||||
|
|
||||||
4. Bootstrapping and includes
|
|
||||||
- At the top of every test, define `WD` if not already defined:
|
|
||||||
```php
|
|
||||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
|
||||||
```
|
|
||||||
- For unit‑style tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
|
|
||||||
- Do NOT include `config.php` for unit‑style tests; it throws if `USE_ENV` is not set.
|
|
||||||
- For integration‑style tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`.
|
|
||||||
|
|
||||||
5. Output and exit codes
|
|
||||||
- Use simple helpers for human‑readable output:
|
|
||||||
```php
|
|
||||||
function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; }
|
|
||||||
function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; }
|
|
||||||
```
|
|
||||||
- Prefer collecting failures and exiting non‑zero when any assertion fails:
|
|
||||||
```php
|
|
||||||
$failed = 0;
|
|
||||||
// ... on failure set $failed = 1;
|
|
||||||
exit($failed);
|
|
||||||
```
|
|
||||||
- End with a concise completion line, e.g., `echo "\n<Name>Test completed.\n";`.
|
|
||||||
|
|
||||||
6. Determinism, time, and randomness
|
|
||||||
- Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source.
|
|
||||||
- Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it.
|
|
||||||
|
|
||||||
7. External I/O and side effects
|
|
||||||
- Never call external services (Stripe, e‑conomic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`.
|
|
||||||
- Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit.
|
|
||||||
|
|
||||||
8. Redis and database (integration‑only)
|
|
||||||
- Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`.
|
|
||||||
- Use unique, namespaced Redis keys for test data (e.g., `test:<feature>:<uuid>`). Clean them up at the end. Do NOT call `FLUSHALL`.
|
|
||||||
- Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit.
|
|
||||||
|
|
||||||
9. Performance budgets
|
|
||||||
- Each unit‑style test script should complete in < 100 ms on a typical dev machine.
|
|
||||||
- Each integration‑style test should complete in < 2 s and must avoid N+1 loops or heavy queries.
|
|
||||||
|
|
||||||
10. When to add or update tests
|
|
||||||
- New route/feature: add at least one unit‑style test for core logic. If behavior depends on configuration/globals, add a minimal integration test.
|
|
||||||
- Bug fix: first reproduce with a failing test; then fix and ensure the test passes.
|
|
||||||
- Refactor: keep behavior‑preserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly.
|
|
||||||
|
|
||||||
11. Maintenance expectations
|
|
||||||
- Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes.
|
|
||||||
- Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries.
|
|
||||||
- Match the project code style and line endings (see `.editorconfig`: UTF‑8, CRLF, 4‑space indent, class brace `next_line`).
|
|
||||||
|
|
||||||
12. Templates
|
|
||||||
- Unit‑style template:
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
|
||||||
// require_once WD . '/modules/foo/classes/bar.php';
|
|
||||||
|
|
||||||
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
|
|
||||||
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
|
|
||||||
$failed = 0;
|
|
||||||
|
|
||||||
// Arrange / Act
|
|
||||||
$sum = 2 + 2;
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
if ($sum === 4) { ok('Basic arithmetic works (2 + 2 = 4)'); } else { fail('Expected 4'); $failed = 1; }
|
|
||||||
|
|
||||||
echo "\nExampleUnitTest completed.\n";
|
|
||||||
exit($failed);
|
|
||||||
```
|
|
||||||
|
|
||||||
- Integration‑style template (Redis example):
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
|
||||||
require_once WD . '/config.php'; // populates $REDIS_CONFIG when USE_ENV=true
|
|
||||||
require_once WD . '/classes/redis.php';
|
|
||||||
|
|
||||||
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
|
|
||||||
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
|
|
||||||
$failed = 0;
|
|
||||||
|
|
||||||
// Use a namespaced test key
|
|
||||||
$key = 'test:redis:example:' . uniqid('', true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$r = new classes\redis();
|
|
||||||
$r->set($key, 'pong');
|
|
||||||
$val = $r->get($key);
|
|
||||||
if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; }
|
|
||||||
} finally {
|
|
||||||
// Best‑effort cleanup
|
|
||||||
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "\nExampleRedisIntegrationTest completed.\n";
|
|
||||||
exit($failed);
|
|
||||||
```
|
|
||||||
|
|
||||||
13. Quick reference
|
|
||||||
- Run unit‑style test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
|
||||||
- Run integration‑style test in Docker: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
|
|
||||||
- Minimal stack for integration: `docker compose up -d traefik redis php1 caddy`
|
|
||||||
|
|
||||||
For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section).
|
|
||||||
|
|
||||||
---
|
|
||||||
apply: always
|
|
||||||
---
|
|
||||||
|
|
||||||
### Creating and maintaining tests — Rules (Project‑specific)
|
|
||||||
|
|
||||||
These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository.
|
|
||||||
|
|
||||||
1. Scope and philosophy
|
|
||||||
- Prefer fast, deterministic CLI scripts over framework‑based tests.
|
|
||||||
- Keep unit‑style tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised.
|
|
||||||
- Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`.
|
|
||||||
|
|
||||||
2. Location and naming
|
|
||||||
- Place tests in `services/nginx/app/tests/<domain>/YourTest.php` where `<domain>` reflects the feature area (e.g., `subusers`, `orders`, `redis`).
|
|
||||||
- File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`).
|
|
||||||
- Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it.
|
|
||||||
|
|
||||||
3. Execution modes
|
|
||||||
- Unit‑style (preferred): run on host with PHP CLI.
|
|
||||||
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
|
||||||
- Integration‑style (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`.
|
|
||||||
- Start minimal stack: `docker compose up -d traefik redis php1 caddy`
|
|
||||||
- Command: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
|
|
||||||
|
|
||||||
4. Bootstrapping and includes
|
|
||||||
- At the top of every test, define `WD` if not already defined:
|
|
||||||
```php
|
|
||||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
|
||||||
```
|
|
||||||
- For unit‑style tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
|
|
||||||
- Do NOT include `config.php` for unit‑style tests; it throws if `USE_ENV` is not set.
|
|
||||||
- For integration‑style tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`.
|
|
||||||
|
|
||||||
5. Output and exit codes
|
|
||||||
- Use simple helpers for human‑readable output:
|
|
||||||
```php
|
|
||||||
function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; }
|
|
||||||
function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; }
|
|
||||||
```
|
|
||||||
- Prefer collecting failures and exiting non‑zero when any assertion fails:
|
|
||||||
```php
|
|
||||||
$failed = 0;
|
|
||||||
// ... on failure set $failed = 1;
|
|
||||||
exit($failed);
|
|
||||||
```
|
|
||||||
- End with a concise completion line, e.g., `echo "\n<Name>Test completed.\n";`.
|
|
||||||
|
|
||||||
6. Determinism, time, and randomness
|
|
||||||
- Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source.
|
|
||||||
- Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it.
|
|
||||||
|
|
||||||
7. External I/O and side effects
|
|
||||||
- Never call external services (Stripe, e‑conomic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`.
|
|
||||||
- Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit.
|
|
||||||
|
|
||||||
8. Redis and database (integration‑only)
|
|
||||||
- Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`.
|
|
||||||
- Use unique, namespaced Redis keys for test data (e.g., `test:<feature>:<uuid>`). Clean them up at the end. Do NOT call `FLUSHALL`.
|
|
||||||
- Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit.
|
|
||||||
|
|
||||||
9. Performance budgets
|
|
||||||
- Each unit‑style test script should complete in < 100 ms on a typical dev machine.
|
|
||||||
- Each integration‑style test should complete in < 2 s and must avoid N+1 loops or heavy queries.
|
|
||||||
|
|
||||||
10. When to add or update tests
|
|
||||||
- New route/feature: add at least one unit‑style test for core logic. If behavior depends on configuration/globals, add a minimal integration test.
|
|
||||||
- Bug fix: first reproduce with a failing test; then fix and ensure the test passes.
|
|
||||||
- Refactor: keep behavior‑preserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly.
|
|
||||||
|
|
||||||
11. Maintenance expectations
|
|
||||||
- Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes.
|
|
||||||
- Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries.
|
|
||||||
- Match the project code style and line endings (see `.editorconfig`: UTF‑8, CRLF, 4‑space indent, class brace `next_line`).
|
|
||||||
|
|
||||||
12. Templates
|
|
||||||
- Unit‑style template:
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
|
||||||
// require_once WD . '/modules/foo/classes/bar.php';
|
|
||||||
|
|
||||||
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
|
|
||||||
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
|
|
||||||
$failed = 0;
|
|
||||||
|
|
||||||
// Arrange / Act
|
|
||||||
$sum = 2 + 2;
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
if ($sum === 4) { ok('Basic arithmetic works (2 + 2 = 4)'); } else { fail('Expected 4'); $failed = 1; }
|
|
||||||
|
|
||||||
echo "\nExampleUnitTest completed.\n";
|
|
||||||
exit($failed);
|
|
||||||
```
|
|
||||||
|
|
||||||
- Integration‑style template (Redis example):
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
|
||||||
require_once WD . '/config.php'; // populates $REDIS_CONFIG when USE_ENV=true
|
|
||||||
require_once WD . '/classes/redis.php';
|
|
||||||
|
|
||||||
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
|
|
||||||
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
|
|
||||||
$failed = 0;
|
|
||||||
|
|
||||||
// Use a namespaced test key
|
|
||||||
$key = 'test:redis:example:' . uniqid('', true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$r = new classes\redis();
|
|
||||||
$r->set($key, 'pong');
|
|
||||||
$val = $r->get($key);
|
|
||||||
if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; }
|
|
||||||
} finally {
|
|
||||||
// Best‑effort cleanup
|
|
||||||
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "\nExampleRedisIntegrationTest completed.\n";
|
|
||||||
exit($failed);
|
|
||||||
```
|
|
||||||
|
|
||||||
13. Quick reference
|
|
||||||
- Run unit‑style test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
|
||||||
- Run integration‑style test in Docker: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
|
|
||||||
- Minimal stack for integration: `docker compose up -d traefik redis php1 caddy`
|
|
||||||
|
|
||||||
For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section).
|
|
||||||
|
|
||||||
|
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||||
|
|||||||
@@ -2,172 +2,18 @@
|
|||||||
apply: always
|
apply: always
|
||||||
---
|
---
|
||||||
|
|
||||||
### Creating and securing routes — Rules (Project‑specific)
|
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||||
|
|
||||||
These rules define how to add HTTP routes to the Copenhagen Truck Wash API and how to secure them consistently. They reflect current patterns in `services/nginx/app/routes` and the helper APIs in `traits\route_t`.
|
# Backend Route Rules
|
||||||
|
|
||||||
1. Location, naming, and structure
|
These rules apply to files under `services/nginx/app/routes` and the classes they call.
|
||||||
- Place route classes under `services/nginx/app/routes`.
|
|
||||||
- File names should describe the domain and end with `Route.php`, e.g., `BookingsRoute.php`, `ModuleSelfServeRoute.php`.
|
|
||||||
- Namespace must be `routes` and each file must define one class that uses `traits\route_t` and implements `run(): void`.
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
namespace routes;
|
|
||||||
use traits\route_t;
|
|
||||||
|
|
||||||
class ExampleRoute
|
1. Keep route handlers thin: validate input, enforce permissions, call domain code, and write the response.
|
||||||
{
|
2. Default to protected endpoints. Use the existing authentication and permission helpers instead of ad hoc access checks.
|
||||||
use route_t;
|
3. Add or update backend tests in the `php1` container whenever route behavior changes.
|
||||||
|
4. Update `openapi.yaml` whenever paths, parameters, request bodies, response envelopes, or permissions change.
|
||||||
public function run(): void
|
5. Prefer deterministic route tests and avoid live external integrations in route coverage.
|
||||||
{
|
6. Use concise API errors and keep sensitive implementation details out of the response body.
|
||||||
// endpoints go here
|
7. When a change touches department, order, or subuser authorization, cover both the allow path and the deny path.
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Registering endpoints (HTTP verbs)
|
|
||||||
- Define endpoints inside `run()` using the helpers provided by `route_t`:
|
|
||||||
- `get($path, $handler, $permissions = [])`
|
|
||||||
- `post($path, $handler, $permissions = [])`
|
|
||||||
- `put($path, $handler, $permissions = [])`
|
|
||||||
- `delete($path, $handler, $permissions = [])`
|
|
||||||
- `patch($path, $handler, $permissions = [])`
|
|
||||||
- `options($path, $handler, $permissions = [])` (useful for CORS preflight when needed)
|
|
||||||
- Handlers are closures that perform validation, authorization, side effects, and write responses via the global `$response` (`classes\response`).
|
|
||||||
- Example (read‑only endpoint):
|
|
||||||
```php
|
|
||||||
$this->get('/example', function () {
|
|
||||||
global $response;
|
|
||||||
$response->success(['message' => 'Hello World!']);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Route paths and local routing
|
|
||||||
- Caddy serves the app directly; Traefik adds an external `/api` prefix for local access.
|
|
||||||
- Direct path in the app: `/foo/bar`.
|
|
||||||
- Local access paths (both work):
|
|
||||||
- `http://localhost/foo/bar` (direct)
|
|
||||||
- `http://localhost/api/foo/bar` (Traefik strip‑prefix `/api` → still routes to `/foo/bar` in the app)
|
|
||||||
- Keep paths meaningful and resource‑oriented. Prefer plural nouns for collections and subpaths for actions when unavoidable (e.g., `/modules/self-serve/lane/status`).
|
|
||||||
|
|
||||||
4. Responses and status codes
|
|
||||||
- Use `$response->success($payload, $status = 200)` for successful results and `$response->error($messageOrPayload, $status)` for failures.
|
|
||||||
- Typical statuses:
|
|
||||||
- 200 OK for successful reads/updates; 201 Created when new resources are created.
|
|
||||||
- 400 Bad Request for validation errors.
|
|
||||||
- 401 Unauthorized when authentication is missing/invalid.
|
|
||||||
- 403 Forbidden when authenticated but lacking permissions.
|
|
||||||
- Return concise, non‑sensitive error messages. Internal details go to logs (see `objects\logs_o`).
|
|
||||||
|
|
||||||
5. Input handling and validation (route_t helpers)
|
|
||||||
- Read parameters via:
|
|
||||||
- `getParameter($name)` or `getParametersAsArray()` (preferred; integrates with `classes\response` parsing)
|
|
||||||
- `fromRequest($name)` (checks JSON body, then `$_POST`, then query)
|
|
||||||
- `fromQuery($name)` and `fromRoute($segmentName)` when relevant
|
|
||||||
- Validate early and fail fast using helpers:
|
|
||||||
- Presence: `requireParameters(['a','b'])`, `isParametersSet(['a','b'])`
|
|
||||||
- Types: `requireType($val, type_string()|type_int()|TYPE_BOOL()|TYPE_ARRAY())`, `requireTypeIn($val, [...])`
|
|
||||||
- Ranges/lengths: `requireMinValue($n, $min)`, `requireMaxValue($n, $max)`, `requireMaxLength($name, $len)`, `requireParameterIntPositive($n, $name)`
|
|
||||||
- Formats/enums: `requireDateFormat($date, FORMAT_DATE())`, `requireInArray($val, $allowed)`
|
|
||||||
|
|
||||||
6. Authentication and authorization (core rules)
|
|
||||||
- Default posture: endpoints are protected and require authentication unless explicitly public.
|
|
||||||
- To check authentication without a specific permission: `isAuthenticated()`.
|
|
||||||
- Enforce permissions inside handlers using:
|
|
||||||
- `requirePermission('some_permission')` — throws 401/403 as appropriate.
|
|
||||||
- `hasPermission('some_permission', $customerNumber = null)` — boolean check (do not throw).
|
|
||||||
- Register permissions for discoverability/administration by supplying the third `$permissions` argument when declaring the route. Keys are permission names; values are human‑readable descriptions. You should still call `requirePermission()` inside the handler to actually enforce.
|
|
||||||
```php
|
|
||||||
$this->get('/modules/self-serve/lane/status', function () {
|
|
||||||
global $response;
|
|
||||||
self::requirePermission('modules_selfserve_lane_status_view');
|
|
||||||
// ...
|
|
||||||
$response->success(['status' => 'OK']);
|
|
||||||
}, [
|
|
||||||
'modules_selfserve_lane_status_view' => 'View self-serve lane status',
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Subusers, permission nodes, and customer context
|
|
||||||
- When a permission corresponds to a subuser permission node, define it with `definePermission($perm, subusers_permission_node_key::CASE)` to keep mapping explicit.
|
|
||||||
- Subuser contexts may target a specific customer via the `X-Customer-Number` header or a `customer_number` parameter; `route_t` exposes this as response meta (`target_customer_number`) when applicable.
|
|
||||||
- Use `allowOwnOrDepartmentAccess($ownPerm, $deptPerm, $targetCustomerNumber, $departmentId, $ownGuard = null, $denyMessage = null)` for the common pattern: allow the principal to act on their own customer scope or fall back to a department/admin permission.
|
|
||||||
- Use `isOwnCustomerContext($targetCustomerNumber)` and `resolveEffectiveCustomerNumber()` when building conditional logic.
|
|
||||||
|
|
||||||
8. Department, order, and special access helpers
|
|
||||||
- Department access: `requireDepartmentAccess($departmentId, $permissionSuffix = null)`, `hasDepartmentAccess(...)`.
|
|
||||||
- Order access: `requireOrderAccess($orderId, $permissionSuffix = null)` (note: indirect access rules may evolve; keep logic minimal in routes).
|
|
||||||
- Plate scanners (API key based): protect endpoints with `requirePlateScannerAuth()`.
|
|
||||||
- Bot protection (public forms): use `requireRecaptcha()` and expect `g_recaptcha_response` in the request.
|
|
||||||
|
|
||||||
9. Public endpoints
|
|
||||||
- Keep public endpoints extremely limited. If an endpoint must be public, you must:
|
|
||||||
- Validate all inputs thoroughly using the helpers above.
|
|
||||||
- Add rate‑limit or abuse‑mitigation where relevant (Redis is available as `classes\redis` for counters/locks; coordinate design with maintainers before introducing new limits).
|
|
||||||
- Prefer `requireRecaptcha()` for anonymous form submissions.
|
|
||||||
|
|
||||||
10. Side effects and idempotency
|
|
||||||
- For POST/PUT/PATCH with side effects, make operations idempotent when feasible (e.g., by honoring a client idempotency key header). If you add such behavior, document it in the route’s docblock and in `openapi.yaml`.
|
|
||||||
- Wrap multi‑step operations with proper validation and permission checks before any external calls (Stripe, e‑conomic, etc.). Do not leak third‑party error payloads directly; map to concise API errors and log details.
|
|
||||||
|
|
||||||
11. Logging and observability
|
|
||||||
- On auth/permission denials, the helpers already log to `logs_o` with structured messages. Avoid duplicating logs for the same event.
|
|
||||||
- For unexpected states you handle gracefully, add targeted logs via `objects\logs_o` with a clear category/key.
|
|
||||||
|
|
||||||
12. OpenAPI contract synchronization
|
|
||||||
- Every public surface change must be reflected in `openapi.yaml` at repo root. Keep paths, methods, parameters, request/response schemas, and error statuses up to date.
|
|
||||||
- Use the same path strings as in the route declaration (remember Traefik’s `/api` is stripped before hitting the app).
|
|
||||||
- If you add or change permissions that users must hold, document them in the endpoint description.
|
|
||||||
|
|
||||||
13. Example: secure command endpoint
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
namespace routes;
|
|
||||||
use traits\route_t;
|
|
||||||
use classes\authentication;
|
|
||||||
|
|
||||||
class LaneCommandRoute
|
|
||||||
{
|
|
||||||
use route_t;
|
|
||||||
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
$this->post('/modules/self-serve/lane/command', function () {
|
|
||||||
global $response;
|
|
||||||
|
|
||||||
// AuthZ
|
|
||||||
self::requirePermission('modules_selfserve_lane_command_execute');
|
|
||||||
|
|
||||||
// Validate input
|
|
||||||
self::requireParameters(['lane_id', 'command']);
|
|
||||||
$laneId = (int) self::getParameter('lane_id');
|
|
||||||
self::requireType($laneId, self::type_int());
|
|
||||||
self::requireMinValue($laneId, 1);
|
|
||||||
|
|
||||||
$cmd = (string) self::getParameter('command');
|
|
||||||
self::requireType($cmd, self::type_string());
|
|
||||||
self::requireInArray($cmd, ['START','STOP','RESERVE','RELEASE','RESET']);
|
|
||||||
|
|
||||||
// Business logic (call into modules/classes)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
$response->success(['ok' => true]);
|
|
||||||
}, [
|
|
||||||
'modules_selfserve_lane_command_execute' => 'Execute self‑serve lane commands',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
14. Quick reference (local)
|
|
||||||
- Bring up minimal stack: `docker compose up -d traefik redis php1 caddy`
|
|
||||||
- Access an app route locally:
|
|
||||||
- `http://localhost/<path>` or `http://localhost/api/<path>`
|
|
||||||
- Tests are CLI scripts under `services/nginx/app/tests`. Prefer unit‑style tests for core logic; see “Creating and maintaining tests — Rules”.
|
|
||||||
|
|
||||||
15. Code style and hygiene
|
|
||||||
- Follow `.editorconfig` (UTF‑8, CRLF, 4‑space indents).
|
|
||||||
- Keep route files cohesive; avoid adding unrelated endpoints to the same class. If a class exceeds ~200–300 lines or mixes domains, split it.
|
|
||||||
- Do not perform heavy bootstrap in route files. Delegate to classes/modules and keep handlers thin: validate → authorize → invoke → respond.
|
|
||||||
|
|
||||||
|
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||||
|
|||||||
@@ -3,31 +3,46 @@ version = 1
|
|||||||
name = "api"
|
name = "api"
|
||||||
|
|
||||||
[setup]
|
[setup]
|
||||||
script = "./scripts/setup.sh"
|
script = '''
|
||||||
|
./scripts/setup.sh
|
||||||
|
'''
|
||||||
|
|
||||||
[setup.win32]
|
[setup.win32]
|
||||||
script = "powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1"
|
script = '''
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\setup.ps1
|
||||||
|
'''
|
||||||
|
|
||||||
[[actions]]
|
[[actions]]
|
||||||
name = "Start API"
|
name = "Start API"
|
||||||
icon = "run"
|
icon = "run"
|
||||||
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action start"
|
command = '''
|
||||||
platform = "win32"
|
docker compose up -d traefik redis mysql-debug php1 caddy
|
||||||
|
'''
|
||||||
|
|
||||||
[[actions]]
|
[[actions]]
|
||||||
name = "Stop API"
|
name = "Stop API"
|
||||||
icon = "run"
|
icon = "run"
|
||||||
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action stop"
|
command = '''
|
||||||
platform = "win32"
|
docker compose down
|
||||||
|
'''
|
||||||
|
|
||||||
[[actions]]
|
[[actions]]
|
||||||
name = "PHP logs"
|
name = "PHP logs"
|
||||||
icon = "debug"
|
icon = "debug"
|
||||||
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action logs -Service php1"
|
command = '''
|
||||||
platform = "win32"
|
docker compose logs -f --tail=200 php1
|
||||||
|
'''
|
||||||
|
|
||||||
[[actions]]
|
[[actions]]
|
||||||
name = "Unit tests"
|
name = "PHP unit tests"
|
||||||
icon = "test"
|
icon = "test"
|
||||||
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action test"
|
command = '''
|
||||||
platform = "win32"
|
docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"
|
||||||
|
'''
|
||||||
|
|
||||||
|
[[actions]]
|
||||||
|
name = "AI workflow check"
|
||||||
|
icon = "debug"
|
||||||
|
command = '''
|
||||||
|
node scripts/sync-ai-workflow.mjs --check
|
||||||
|
'''
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Create GitHub Issue for Copilot
|
- name: Create GitHub Issue for Copilot
|
||||||
env:
|
env:
|
||||||
@@ -22,7 +22,18 @@ jobs:
|
|||||||
TASK: ${{ github.event.inputs.task }}
|
TASK: ${{ github.event.inputs.task }}
|
||||||
ACTOR: ${{ github.actor }}
|
ACTOR: ${{ github.actor }}
|
||||||
run: |
|
run: |
|
||||||
|
BODY="$(cat <<EOF
|
||||||
|
Assigned to Copilot by @$ACTOR.
|
||||||
|
|
||||||
|
Task
|
||||||
|
$TASK
|
||||||
|
|
||||||
|
AI workflow notes
|
||||||
|
- Generated assistant metadata is synchronized from the canonical AI workflow bundle.
|
||||||
|
- Run `node scripts/sync-ai-workflow.mjs --check` if assistant metadata changed.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
gh issue create \
|
gh issue create \
|
||||||
--title "Copilot Task: $TASK" \
|
--title "Copilot Task: $TASK" \
|
||||||
--body "Assigned to Copilot by @$ACTOR. Description: $TASK" \
|
--body "$BODY" \
|
||||||
--label "copilot-task"
|
--label "copilot-task"
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Check AI workflow sync
|
||||||
|
run: node scripts/sync-ai-workflow.mjs --check
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
+2
-1
@@ -9,4 +9,5 @@
|
|||||||
/services/php/logs/
|
/services/php/logs/
|
||||||
/.idea/
|
/.idea/
|
||||||
.env
|
.env
|
||||||
/services/caddy/logs*
|
/services/caddy/logs*
|
||||||
|
/.tmp/
|
||||||
|
|||||||
+19
-139
@@ -1,148 +1,28 @@
|
|||||||
### Copenhagen Truck Wash API — Development Guidelines (Project‑specific)
|
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||||
|
|
||||||
#### Scope
|
# Copenhagen Truck Wash API Development Guidelines
|
||||||
This document captures project‑specific knowledge for building, configuring, testing, and extending the API. It assumes an advanced developer familiar with Docker, PHP 8.2, and HTTP APIs.
|
|
||||||
|
|
||||||
---
|
This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the backend repository.
|
||||||
|
|
||||||
### Build and Configuration
|
## Build And Run
|
||||||
|
|
||||||
- Stack overview (local):
|
- Project root: `services/nginx/app` is the effective PHP application root.
|
||||||
- Reverse proxy/router: Traefik 2.x (`docker-compose.yml` service `traefik`).
|
- Setup: use `./scripts/setup.sh` on POSIX or `powershell -ExecutionPolicy Bypass -File .\scripts\setup.ps1` on Windows.
|
||||||
- Web server: Caddy (`caddy`) serving the PHP app from `services/nginx/app` and proxied by Traefik.
|
- Start local API stack: `docker compose up -d traefik redis mysql-debug php1 caddy`.
|
||||||
- PHP runtime: Multiple PHP‑FPM containers (`php1`..`php5`), sharing the bind‑mounted app directory.
|
- Tail logs with `docker compose logs -f --tail=200 php1`.
|
||||||
- Redis: `redis` for caching/queues/locks.
|
|
||||||
|
|
||||||
- App location: `services/nginx/app` is the effective application root (many scripts/tests derive `WD` to point here).
|
## Testing
|
||||||
|
|
||||||
- Composer and dependencies:
|
- Supported backend validation runs in `php1`.
|
||||||
- `services/php/Dockerfile` installs Composer and PHP extensions.
|
- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`.
|
||||||
- `services/php/docker-entrypoint.sh` performs a guarded Composer install on `php1` at container start when `AUTO_COMPOSER_INSTALL=true` and `composer.json` is present.
|
- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`.
|
||||||
- App dependencies live under `services/nginx/app/composer.json` (note: very light, primarily runtime libs; dev tool `rector/rector`).
|
- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`.
|
||||||
|
- Prefer the narrowest suite that proves the change, then run the broader suite that matches the risk.
|
||||||
|
|
||||||
- Configuration source of truth during containerized runs is environment variables consumed by `services/nginx/app/config.php`.
|
## Workflow Notes
|
||||||
- `config.php` requires `USE_ENV=true`; otherwise it throws an exception. Many CLI scripts/tests bypass `config.php` entirely to remain env‑agnostic.
|
|
||||||
|
|
||||||
- Local run targets and routing:
|
- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.
|
||||||
- Traefik exposes:
|
- Route and schema changes require matching updates to `openapi.yaml`.
|
||||||
- `http://localhost` → routes to Caddy → app (HTTP only for dev).
|
- Runtime OpenAI product behavior is out of scope for this workflow bundle unless a task explicitly changes product code.
|
||||||
- `https://localhost` → also mapped, using Traefik’s default/self‑signed dev cert.
|
|
||||||
- `http(s)://localhost/api/*` → Traefik strip‑prefix middleware forwards to Caddy; the app sees paths without the `/api` prefix.
|
|
||||||
- For production‑like HTTPS with real certs, see `README.md` for `LETSENCRYPT_PATH` mounting strategy (only needed if you want the exact `api.truckwash.dk` TLS behavior locally).
|
|
||||||
|
|
||||||
- Minimal bring‑up for local development:
|
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||||
- Prerequisites: Docker Desktop 4.x+.
|
|
||||||
- First run will build PHP images and start dependent services. Composer install runs automatically on `php1`.
|
|
||||||
- Recommended minimal set:
|
|
||||||
- `docker compose up -d traefik redis php1 caddy`
|
|
||||||
- Full set (scale out PHP or add observability as needed):
|
|
||||||
- `docker compose up -d` (starts `traefik`, `redis`, `caddy`, `php1`..`php5`, and other declared services).
|
|
||||||
- Logs:
|
|
||||||
- `docker compose logs -f caddy`
|
|
||||||
- `docker compose logs -f php1`
|
|
||||||
- `docker compose logs -f traefik`
|
|
||||||
|
|
||||||
- Security note: `docker-compose.yml` currently embeds sensitive env values (DB, API tokens). Treat the file as secret in private repos; never re‑publish as is. Prefer `.env` overrides and secrets providers for wider teams.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
The repository does not use PHPUnit for the app. Instead, tests are lightweight CLI scripts under `services/nginx/app/tests`. Conventions:
|
|
||||||
|
|
||||||
- Test style
|
|
||||||
- Self‑contained procedural PHP scripts intended to be executed with `php`. No framework required.
|
|
||||||
- Many tests define the `WD` constant to the app root and then `require_once` specific class/trait/interface files they exercise.
|
|
||||||
- Integration‑style scripts that need configuration will rely on `services/nginx/app/config.php` and so must run within a properly provisioned environment (Docker containers with `USE_ENV=true`).
|
|
||||||
- Fast unit‑style scripts should avoid `config.php` and any I/O; they manually include only what’s needed and/or use test doubles.
|
|
||||||
|
|
||||||
- Running tests from host (fastest path)
|
|
||||||
- Prereq: PHP CLI available on host. Verified with:
|
|
||||||
- `php -v` → observed on our env: `PHP 8.2.30 (cli)`
|
|
||||||
- Example: an existing, fully self‑contained test validating subuser permission wiring:
|
|
||||||
- Command:
|
|
||||||
- `php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php`
|
|
||||||
- Verified output (captured):
|
|
||||||
-
|
|
||||||
```
|
|
||||||
✔ SELFSERVE_ADD is granted as expected
|
|
||||||
✔ SELFSERVE_LIST is not granted as expected
|
|
||||||
✔ SELFSERVE_EDIT is not granted as expected
|
|
||||||
✔ SELFSERVEDELETE is not granted as expected
|
|
||||||
SelfservePermissionInitTest completed.
|
|
||||||
```
|
|
||||||
|
|
||||||
- Running tests inside Docker (when host PHP is unavailable or when env is required)
|
|
||||||
- Ensure containers are up: `docker compose up -d traefik redis php1 caddy`
|
|
||||||
- Execute a test within `php1`:
|
|
||||||
- `docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php`
|
|
||||||
- For integration tests that depend on `config.php` (e.g., Redis/DB), environment variables are preconfigured in `docker-compose.yml` for the PHP services. Running inside `php1` will satisfy `USE_ENV=true`.
|
|
||||||
|
|
||||||
- Adding a new test
|
|
||||||
- Place the file under `services/nginx/app/tests/<domain>/YourTest.php`.
|
|
||||||
- At top of the file, define `WD` if not already defined:
|
|
||||||
```php
|
|
||||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
|
||||||
```
|
|
||||||
- Prefer self‑contained tests that avoid I/O. If you need to touch internal classes without Composer autoloading, include files directly, mirroring existing tests.
|
|
||||||
- If the test must hit Redis/DB or rely on globals from `config.php`, run it inside a PHP container (`php1`) with `USE_ENV=true`.
|
|
||||||
|
|
||||||
- Demonstration: creating and running a minimal test
|
|
||||||
- We created a temporary, env‑agnostic test at `services/nginx/app/tests/examples/HelloWorldTest.php` with these semantics:
|
|
||||||
- Define `WD`, perform trivial assertions, and print success markers.
|
|
||||||
- Command executed and verified output:
|
|
||||||
- Command:
|
|
||||||
- `php services/nginx/app/tests/examples/HelloWorldTest.php`
|
|
||||||
- Output (captured):
|
|
||||||
-
|
|
||||||
```
|
|
||||||
✔ Basic arithmetic works (2 + 2 = 4)
|
|
||||||
✔ WD points to the application root: C:\\Users\\2jepp\\PhpstormProjects\\api\\services\\nginx\\app
|
|
||||||
HelloWorldTest completed.
|
|
||||||
```
|
|
||||||
- The example file was removed afterwards to keep the repository unchanged. You can replicate by creating a similar file and removing it after execution.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Additional Development Information
|
|
||||||
|
|
||||||
- Routing and HTTP surface
|
|
||||||
- Routes live under `services/nginx/app/routes`. Example route `exampleRoute.php` exposes `GET /example` returning `{"message":"Hello World!"}`. Local access paths (with Traefik):
|
|
||||||
- `http://localhost/example` (direct)
|
|
||||||
- `http://localhost/api/example` (Traefik strip‑prefix `/api` → still routes to `/example` in the app).
|
|
||||||
- The OpenAPI contract is at the repo root `openapi.yaml` (large, authoritative). Keep it synchronized with implemented routes and payloads.
|
|
||||||
|
|
||||||
- Module layout and traits
|
|
||||||
- Domain modules live in `services/nginx/app/modules/*` and are heavily trait‑based. Tests often pull in precise files from here to avoid full app bootstrap.
|
|
||||||
- Example: subusers module (`modules/subusers/...`) provides permission node containers and helpers. The test `tests/subusers/SelfservePermissionInitTest.php` demonstrates overriding DB‑backed methods to inject permissions for fast, deterministic checks.
|
|
||||||
|
|
||||||
- Config and globals
|
|
||||||
- `services/nginx/app/config.php` populates globals like `$CONFIG_DB`, `$REDIS_CONFIG`, etc., but only when `USE_ENV=true`. If you see tests throwing “Environment variables are not set”, run them inside Docker or set required envs for host PHP.
|
|
||||||
|
|
||||||
- Code style
|
|
||||||
- `.editorconfig` at repo root configures formatting. Key PHP rules:
|
|
||||||
- Encoding: UTF‑8 with CRLF line endings.
|
|
||||||
- Indent: 4 spaces; continuation indent 4.
|
|
||||||
- Class brace style: next_line; function/method blank lines: 1.
|
|
||||||
- Import sorting: alphabetic; various alignment toggles are disabled.
|
|
||||||
- Match existing patterns (procedural scripts for tests; namespacing in app code; traits for cross‑cutting concerns). Avoid adding new frameworks for tests unless explicitly requested.
|
|
||||||
|
|
||||||
- Composer / Autoload
|
|
||||||
- There is no Composer autoload configured for the app code in `composer.json`; tests include files directly. If you introduce autoloading, coordinate with Docker entrypoint behaviors and ensure zero‑downtime for existing scripts.
|
|
||||||
|
|
||||||
- Performance & reliability hints for tests
|
|
||||||
- Prefer small, deterministic CLI scripts; inject dependencies and override I/O methods (as shown in `SelfservePermissionInitTest.php`).
|
|
||||||
- Avoid hitting external services (Stripe, e‑conomic, MinIO) from tests; instead, stub/override or provide fakes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Quick Reference
|
|
||||||
|
|
||||||
- Bring up minimal dev stack:
|
|
||||||
- `docker compose up -d traefik redis php1 caddy`
|
|
||||||
|
|
||||||
- Run a fast, self‑contained test (host PHP):
|
|
||||||
- `php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php`
|
|
||||||
|
|
||||||
- Run a test inside Docker (env‑backed):
|
|
||||||
- `docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php`
|
|
||||||
|
|||||||
@@ -26,6 +26,32 @@ The API is accessible at:
|
|||||||
- `https://localhost` (using Traefik default cert)
|
- `https://localhost` (using Traefik default cert)
|
||||||
- `http(s)://localhost/api/` (proxied with `/api` prefix stripped)
|
- `http(s)://localhost/api/` (proxied with `/api` prefix stripped)
|
||||||
|
|
||||||
|
### Test Gateway Container
|
||||||
|
To run a real PHP edge agent as a disposable Dockerized test gateway against the local stack, first create an install token from the edge gateway admin UI, then start the helper:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\test-gateway.ps1 start --install-token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-gateway.sh start --install-token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper will:
|
||||||
|
- start the local compose dependencies if needed
|
||||||
|
- claim a gateway through `http://localhost/api/edge-agent/claim`
|
||||||
|
- write the generated config to `.tmp/test-gateway/test-gateway.json`
|
||||||
|
- build `services/edge-agent/Dockerfile.test-gateway`
|
||||||
|
- run the PHP agent container on the local compose network
|
||||||
|
|
||||||
|
Useful follow-up commands:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\test-gateway.ps1 logs
|
||||||
|
.\scripts\test-gateway.ps1 status
|
||||||
|
.\scripts\test-gateway.ps1 stop
|
||||||
|
```
|
||||||
|
|
||||||
To start all services including multiple PHP workers and development tools (Jaeger, Portainer):
|
To start all services including multiple PHP workers and development tools (Jaeger, Portainer):
|
||||||
```powershell
|
```powershell
|
||||||
docker compose --profile dev up -d
|
docker compose --profile dev up -d
|
||||||
@@ -74,6 +100,25 @@ $env:RUN_INTEGRATION_TESTS='1'
|
|||||||
composer test:integration
|
composer test:integration
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Public Staging Edge-Gateway Smoke
|
||||||
|
`api.truckwash.io:4433` is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from `services/nginx/app/resources/edge-gateway-agent`, not from the legacy `dist/agent.mjs` output or a separate runtime mount.
|
||||||
|
|
||||||
|
To verify the public staging stack after a deploy, use a real installer token and run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node .\scripts\staging-edge-gateway-smoke.mjs --install-token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ./scripts/staging-edge-gateway-smoke.mjs --install-token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke check fails unless all of these return `200` from the public domain:
|
||||||
|
- `/ping`
|
||||||
|
- `/edge-agent/artifacts/agent.php`
|
||||||
|
- `/edge-agent/artifacts/truckwash-edge-agent.service`
|
||||||
|
- `/edge-agent/install.sh?token=<real token>`
|
||||||
|
|
||||||
### API Test Suite
|
### API Test Suite
|
||||||
The `Api` suite exercises real HTTP endpoints instead of calling route handlers in-process.
|
The `Api` suite exercises real HTTP endpoints instead of calling route handlers in-process.
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ services:
|
|||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs:/var/log/php
|
- ./services/php/logs:/var/log/php
|
||||||
|
|
||||||
@@ -123,7 +122,6 @@ services:
|
|||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
|
|
||||||
n8n:
|
n8n:
|
||||||
|
|||||||
+2
-40
@@ -80,16 +80,6 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
edge-broker:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: services/edge-broker/Dockerfile
|
|
||||||
container_name: edge-broker
|
|
||||||
environment:
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
ports:
|
|
||||||
- "4300:4300"
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2.7.6-alpine
|
image: caddy:2.7.6-alpine
|
||||||
container_name: caddy
|
container_name: caddy
|
||||||
@@ -159,7 +149,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- php-staging
|
- php-staging
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/staging:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro
|
- ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro
|
||||||
- ./services/caddy/logs-staging:/var/log/caddy
|
- ./services/caddy/logs-staging:/var/log/caddy
|
||||||
labels:
|
labels:
|
||||||
@@ -206,17 +196,13 @@ services:
|
|||||||
container_name: php1
|
container_name: php1
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- edge-broker
|
|
||||||
command: ["php-fpm"]
|
command: ["php-fpm"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "true"
|
AUTO_COMPOSER_INSTALL: "true"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs:/var/log/php
|
- ./services/php/logs:/var/log/php
|
||||||
|
|
||||||
@@ -227,17 +213,13 @@ services:
|
|||||||
container_name: php2
|
container_name: php2
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- edge-broker
|
|
||||||
command: ["php-fpm"]
|
command: ["php-fpm"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs:/var/log/php
|
- ./services/php/logs:/var/log/php
|
||||||
|
|
||||||
@@ -248,17 +230,13 @@ services:
|
|||||||
container_name: php3
|
container_name: php3
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- edge-broker
|
|
||||||
command: ["php-fpm"]
|
command: ["php-fpm"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs:/var/log/php
|
- ./services/php/logs:/var/log/php
|
||||||
|
|
||||||
@@ -269,17 +247,13 @@ services:
|
|||||||
container_name: php4
|
container_name: php4
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- edge-broker
|
|
||||||
command: ["php-fpm"]
|
command: ["php-fpm"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs:/var/log/php
|
- ./services/php/logs:/var/log/php
|
||||||
|
|
||||||
@@ -290,17 +264,13 @@ services:
|
|||||||
container_name: php5
|
container_name: php5
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- edge-broker
|
|
||||||
command: ["php-fpm"]
|
command: ["php-fpm"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs:/var/log/php
|
- ./services/php/logs:/var/log/php
|
||||||
|
|
||||||
@@ -311,17 +281,13 @@ services:
|
|||||||
container_name: php-staging
|
container_name: php-staging
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis-staging
|
- redis-staging
|
||||||
- edge-broker
|
|
||||||
command: ["php-fpm"]
|
command: ["php-fpm"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env.staging
|
- .env.staging
|
||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/staging:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs-staging:/var/log/php
|
- ./services/php/logs-staging:/var/log/php
|
||||||
|
|
||||||
@@ -332,17 +298,13 @@ services:
|
|||||||
container_name: php-cron
|
container_name: php-cron
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- edge-broker
|
|
||||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
|
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
- ./services/php/logs:/var/log/php
|
- ./services/php/logs:/var/log/php
|
||||||
portainer:
|
portainer:
|
||||||
|
|||||||
+13
-1
@@ -2195,6 +2195,17 @@ paths:
|
|||||||
two_factor_enabled:
|
two_factor_enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Indicates if 2FA is enabled for this account
|
description: Indicates if 2FA is enabled for this account
|
||||||
|
runtime_config:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
economic:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
transaction_draft_customer_number:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
additionalProperties: false
|
||||||
|
additionalProperties: true
|
||||||
'400':
|
'400':
|
||||||
$ref: '#/components/responses/BadRequest'
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
@@ -12031,12 +12042,13 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
module: { type: string, enum: [economic] }
|
module: { type: string, enum: [economic] }
|
||||||
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] }
|
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] }
|
||||||
type: { type: string, enum: [string, int] }
|
type: { type: string, enum: [string, int] }
|
||||||
value:
|
value:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: integer
|
- type: integer
|
||||||
|
nullable: true
|
||||||
required: [module, variable, type, value]
|
required: [module, variable, type, value]
|
||||||
|
|
||||||
RecaptchaConfigEntry:
|
RecaptchaConfigEntry:
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import process from "node:process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
||||||
|
|
||||||
|
export function normalizeBaseUrl(url) {
|
||||||
|
return String(url || "").trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArgs(argv = process.argv.slice(2)) {
|
||||||
|
const options = {
|
||||||
|
baseUrl: DEFAULT_STAGING_BASE_URL,
|
||||||
|
installToken: "",
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
const next = argv[index + 1];
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case "--base-url":
|
||||||
|
options.baseUrl = String(next || "").trim() || DEFAULT_STAGING_BASE_URL;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--install-token":
|
||||||
|
options.installToken = String(next || "").trim();
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
options.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChecks(baseUrl, installToken) {
|
||||||
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "Ping",
|
||||||
|
url: `${normalizedBaseUrl}/ping`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Agent PHP artifact",
|
||||||
|
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Service unit artifact",
|
||||||
|
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Installer script",
|
||||||
|
url: `${normalizedBaseUrl}/edge-agent/install.sh?token=${encodeURIComponent(installToken)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
process.stdout.write(`Usage:
|
||||||
|
node scripts/staging-edge-gateway-smoke.mjs --install-token <token> [--base-url <url>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--install-token <token> Real edge-gateway install token used to validate install.sh.
|
||||||
|
--base-url <url> Public staging base URL. Default: ${DEFAULT_STAGING_BASE_URL}
|
||||||
|
--help Show this help text.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewBody(body, limit = 200) {
|
||||||
|
const normalized = String(body || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (normalized.length <= limit) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return `${normalized.slice(0, limit)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSmoke({ baseUrl, installToken }) {
|
||||||
|
if (String(installToken || "").trim() === "") {
|
||||||
|
throw new Error("Missing required --install-token value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const checks = buildChecks(baseUrl, installToken);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
||||||
|
const response = await fetch(check.url);
|
||||||
|
const body = await response.text();
|
||||||
|
const result = {
|
||||||
|
...check,
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
bodyPreview: previewBody(body),
|
||||||
|
};
|
||||||
|
results.push(result);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`${check.name} failed with HTTP ${response.status} at ${check.url}. ` +
|
||||||
|
`Body preview: ${result.bodyPreview || "<empty>"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs();
|
||||||
|
|
||||||
|
if (options.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await runSmoke(options);
|
||||||
|
for (const result of results) {
|
||||||
|
process.stdout.write(`[staging-smoke] OK ${result.status} ${result.name}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectExecution = process.argv[1]
|
||||||
|
&& import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||||
|
|
||||||
|
if (isDirectExecution) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`[staging-smoke] ERROR: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_STAGING_BASE_URL,
|
||||||
|
buildChecks,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
parseArgs,
|
||||||
|
} from "./staging-edge-gateway-smoke.mjs";
|
||||||
|
|
||||||
|
test("normalizeBaseUrl strips trailing slashes", () => {
|
||||||
|
assert.equal(normalizeBaseUrl("https://api.truckwash.io:4433///"), DEFAULT_STAGING_BASE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs accepts base URL and install token", () => {
|
||||||
|
const options = parseArgs([
|
||||||
|
"--base-url",
|
||||||
|
"https://staging.example.test/",
|
||||||
|
"--install-token",
|
||||||
|
"token-123",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(options.baseUrl, "https://staging.example.test/");
|
||||||
|
assert.equal(options.installToken, "token-123");
|
||||||
|
assert.equal(options.help, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildChecks targets the public staging endpoints", () => {
|
||||||
|
const checks = buildChecks(DEFAULT_STAGING_BASE_URL, "abc 123");
|
||||||
|
|
||||||
|
assert.deepEqual(checks.map((check) => check.url), [
|
||||||
|
"https://api.truckwash.io:4433/ping",
|
||||||
|
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
|
||||||
|
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
||||||
|
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const scriptSource = await fs.readFile(__filename, "utf8");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const workspaceRoot = path.resolve(args.root ?? path.join(__dirname, ".."));
|
||||||
|
const workflowPath = path.join(workspaceRoot, ".ai-workflow", "workflow.md");
|
||||||
|
const manifestPath = path.join(workspaceRoot, ".ai-workflow", "manifest.json");
|
||||||
|
|
||||||
|
const workflow = await fs.readFile(workflowPath, "utf8");
|
||||||
|
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||||
|
|
||||||
|
validateManifest(manifest);
|
||||||
|
|
||||||
|
const outputs = buildOutputs({
|
||||||
|
manifest,
|
||||||
|
workflow,
|
||||||
|
scriptSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.mode === "check") {
|
||||||
|
const drifted = await findDriftedOutputs(workspaceRoot, outputs);
|
||||||
|
if (drifted.length > 0) {
|
||||||
|
console.error("AI workflow outputs are out of sync:");
|
||||||
|
for (const drift of drifted) {
|
||||||
|
console.error(`- ${drift}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("AI workflow outputs are in sync.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = await writeOutputs(workspaceRoot, outputs);
|
||||||
|
if (changed.length === 0) {
|
||||||
|
console.log("AI workflow outputs are already up to date.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Updated AI workflow outputs:");
|
||||||
|
for (const changedPath of changed) {
|
||||||
|
console.log(`- ${changedPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
mode: null,
|
||||||
|
root: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
|
||||||
|
if (arg === "--write") {
|
||||||
|
args.mode = "write";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--check") {
|
||||||
|
args.mode = "check";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--root") {
|
||||||
|
args.root = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.mode) {
|
||||||
|
throw new Error("Expected --write or --check.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(manifest) {
|
||||||
|
const requiredKeys = ["projects", "assistants", "commands", "generated_outputs", "sync_targets"];
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
if (!(key in manifest)) {
|
||||||
|
throw new Error(`Manifest is missing required key: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenPaths = new Set();
|
||||||
|
for (const output of manifest.generated_outputs) {
|
||||||
|
if (!output.path || !output.template) {
|
||||||
|
throw new Error("Each generated output must define path and template.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenPaths.has(output.path)) {
|
||||||
|
throw new Error(`Duplicate generated output path: ${output.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPaths.add(output.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputs(context) {
|
||||||
|
const outputs = [];
|
||||||
|
for (const output of context.manifest.generated_outputs) {
|
||||||
|
outputs.push({
|
||||||
|
path: normalizeRelativePath(output.path),
|
||||||
|
content: renderTemplate(output, context),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(output, context) {
|
||||||
|
const templateMap = {
|
||||||
|
codex_workspace_environment: () => renderWorkspaceCodexEnvironment(context.manifest),
|
||||||
|
codex_project_environment: () => renderProjectCodexEnvironment(context.manifest, output.project),
|
||||||
|
aiassistant_backend_tests_rule: () =>
|
||||||
|
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_tests_lines),
|
||||||
|
aiassistant_backend_routes_rule: () =>
|
||||||
|
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_routes_lines),
|
||||||
|
aiassistant_frontend_tests_rule: () =>
|
||||||
|
renderAiAssistantRule(context.manifest.projects["front-end-vue"].generated_content.aiassistant_tests_lines),
|
||||||
|
junie_backend_guidelines: () =>
|
||||||
|
renderJunieGuidelines(context.manifest.projects["backend-php"].generated_content.junie_lines),
|
||||||
|
junie_frontend_guidelines: () =>
|
||||||
|
renderJunieGuidelines(context.manifest.projects["front-end-vue"].generated_content.junie_lines),
|
||||||
|
copilot_dispatcher_workflow: () => renderCopilotWorkflow(context.manifest.copilot),
|
||||||
|
workflow_snapshot: () => renderWorkflowSnapshot(context.workflow, output.project),
|
||||||
|
project_snapshot_manifest: () => renderProjectSnapshotManifest(context.manifest, output.project),
|
||||||
|
project_sync_script: () => context.scriptSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = templateMap[output.template];
|
||||||
|
if (!renderer) {
|
||||||
|
throw new Error(`Unsupported template: ${output.template}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureTrailingNewline(renderer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkspaceCodexEnvironment(manifest) {
|
||||||
|
return renderCodexEnvironment({
|
||||||
|
name: manifest.workspace.name,
|
||||||
|
setup: manifest.workspace.codex_environment.setup,
|
||||||
|
actions: manifest.workspace.codex_environment.actions,
|
||||||
|
manifest,
|
||||||
|
workspaceScoped: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectCodexEnvironment(manifest, projectKey) {
|
||||||
|
const project = manifest.projects[projectKey];
|
||||||
|
return renderCodexEnvironment({
|
||||||
|
name: project.codex_environment.name,
|
||||||
|
setup: project.commands.setup,
|
||||||
|
actions: project.codex_environment.actions,
|
||||||
|
manifest,
|
||||||
|
projectKey,
|
||||||
|
workspaceScoped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCodexEnvironment({ name, setup, actions, manifest, projectKey = null, workspaceScoped }) {
|
||||||
|
const lines = [
|
||||||
|
"# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY",
|
||||||
|
"version = 1",
|
||||||
|
`name = "${name}"`,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
appendTomlCommandBlock(lines, "setup", setup);
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("[[actions]]");
|
||||||
|
lines.push(`name = "${escapeTomlString(action.name)}"`);
|
||||||
|
lines.push(`icon = "${escapeTomlString(action.icon)}"`);
|
||||||
|
lines.push(`command = ${renderTomlMultiline(resolveActionCommand(action, manifest, projectKey, workspaceScoped))}`);
|
||||||
|
|
||||||
|
if (action.platform) {
|
||||||
|
lines.push(`platform = "${escapeTomlString(action.platform)}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTomlCommandBlock(lines, key, commandSpec = {}) {
|
||||||
|
lines.push(`[${key}]`);
|
||||||
|
lines.push(`script = ${renderTomlMultiline(commandSpec.default ?? "")}`);
|
||||||
|
|
||||||
|
if (commandSpec.win32 !== undefined) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`[${key}.win32]`);
|
||||||
|
lines.push(`script = ${renderTomlMultiline(commandSpec.win32)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActionCommand(action, manifest, projectKey, workspaceScoped) {
|
||||||
|
if (action.literal_command) {
|
||||||
|
return action.literal_command.default ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action.command_id) {
|
||||||
|
throw new Error(`Action ${action.name} is missing command_id or literal_command.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedProjectKey = action.project ?? projectKey;
|
||||||
|
if (!resolvedProjectKey) {
|
||||||
|
throw new Error(`Action ${action.name} does not resolve to a project.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = manifest.projects[resolvedProjectKey];
|
||||||
|
const command = project.commands[action.command_id]?.default ?? "";
|
||||||
|
|
||||||
|
if (!workspaceScoped) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinCommands([`cd ${project.relative_root}`, command]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAiAssistantRule(lines) {
|
||||||
|
return [
|
||||||
|
"---",
|
||||||
|
"apply: always",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
||||||
|
"",
|
||||||
|
...lines,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJunieGuidelines(lines) {
|
||||||
|
return [
|
||||||
|
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
||||||
|
"",
|
||||||
|
...lines,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCopilotWorkflow(copilot) {
|
||||||
|
const body = copilot.body_lines
|
||||||
|
.map((line) => line.replaceAll("{{ACTOR}}", "$ACTOR").replaceAll("{{TASK}}", "$TASK"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
`name: ${copilot.workflow_name}`,
|
||||||
|
"on:",
|
||||||
|
" workflow_dispatch:",
|
||||||
|
" inputs:",
|
||||||
|
" task:",
|
||||||
|
` description: '${copilot.input_description}'`,
|
||||||
|
" required: true",
|
||||||
|
" type: string",
|
||||||
|
"",
|
||||||
|
"jobs:",
|
||||||
|
" assign-task:",
|
||||||
|
" runs-on: ubuntu-latest",
|
||||||
|
" permissions:",
|
||||||
|
" issues: write",
|
||||||
|
" steps:",
|
||||||
|
" - name: Checkout repository",
|
||||||
|
" uses: actions/checkout@v4",
|
||||||
|
"",
|
||||||
|
" - name: Create GitHub Issue for Copilot",
|
||||||
|
" env:",
|
||||||
|
" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
|
||||||
|
" TASK: ${{ github.event.inputs.task }}",
|
||||||
|
" ACTOR: ${{ github.actor }}",
|
||||||
|
" run: |",
|
||||||
|
" BODY=\"$(cat <<EOF",
|
||||||
|
...body.split("\n").map((line) => ` ${line}`),
|
||||||
|
" EOF",
|
||||||
|
" )\"",
|
||||||
|
" gh issue create \\",
|
||||||
|
` --title "${copilot.title_prefix} $TASK" \\`,
|
||||||
|
" --body \"$BODY\" \\",
|
||||||
|
` --label "${copilot.issue_label}"`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkflowSnapshot(workflow, projectKey) {
|
||||||
|
return [
|
||||||
|
`<!-- AUTOGENERATED SNAPSHOT for ${projectKey}: refresh from the canonical workspace with \`node scripts/sync-ai-workflow.mjs --write\`. -->`,
|
||||||
|
"",
|
||||||
|
workflow.trimEnd(),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectSnapshotManifest(manifest, projectKey) {
|
||||||
|
const snapshotManifest = buildProjectSnapshotManifest(manifest, projectKey);
|
||||||
|
return `${JSON.stringify(snapshotManifest, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectSnapshotManifest(manifest, projectKey) {
|
||||||
|
const project = JSON.parse(JSON.stringify(manifest.projects[projectKey]));
|
||||||
|
const prefix = `${manifest.projects[projectKey].relative_root}/`;
|
||||||
|
|
||||||
|
project.relative_root = ".";
|
||||||
|
|
||||||
|
const snapshotOutputs = manifest.generated_outputs
|
||||||
|
.filter((output) => output.project === projectKey)
|
||||||
|
.filter((output) => output.template !== "workflow_snapshot")
|
||||||
|
.filter((output) => output.template !== "project_snapshot_manifest")
|
||||||
|
.filter((output) => output.template !== "project_sync_script")
|
||||||
|
.map((output) => ({
|
||||||
|
...output,
|
||||||
|
path: normalizeRelativePath(output.path.slice(prefix.length)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const snapshotManifest = {
|
||||||
|
"version": manifest.version,
|
||||||
|
"snapshot_of": projectKey,
|
||||||
|
"projects": {
|
||||||
|
[projectKey]: project,
|
||||||
|
},
|
||||||
|
"assistants": manifest.assistants,
|
||||||
|
"commands": manifest.commands,
|
||||||
|
"generated_outputs": snapshotOutputs,
|
||||||
|
"sync_targets": {
|
||||||
|
[projectKey]: {
|
||||||
|
"source_root": ".",
|
||||||
|
"destination": manifest.sync_targets[projectKey]?.destination ?? "",
|
||||||
|
"supported_metadata_dirs": manifest.sync_targets[projectKey]?.supported_metadata_dirs ?? [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (projectKey === "backend-php" && manifest.copilot) {
|
||||||
|
snapshotManifest.copilot = manifest.copilot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectKey === "front-end-vue" && manifest.unsupported) {
|
||||||
|
snapshotManifest.unsupported = manifest.unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshotManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDriftedOutputs(workspaceRoot, outputs) {
|
||||||
|
const drifted = [];
|
||||||
|
for (const output of outputs) {
|
||||||
|
const absolutePath = path.join(workspaceRoot, output.path);
|
||||||
|
const currentContent = await readIfExists(absolutePath);
|
||||||
|
if (currentContent !== output.content) {
|
||||||
|
drifted.push(output.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return drifted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeOutputs(workspaceRoot, outputs) {
|
||||||
|
const changed = [];
|
||||||
|
for (const output of outputs) {
|
||||||
|
const absolutePath = path.join(workspaceRoot, output.path);
|
||||||
|
const currentContent = await readIfExists(absolutePath);
|
||||||
|
if (currentContent === output.content) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||||
|
await fs.writeFile(absolutePath, output.content, "utf8");
|
||||||
|
changed.push(output.path);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIfExists(filePath) {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(filePath, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === "ENOENT") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinCommands(commands) {
|
||||||
|
return commands.filter((command) => command && command.trim() !== "").join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTomlMultiline(value) {
|
||||||
|
return `'''\n${value ?? ""}\n'''`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeTomlString(value) {
|
||||||
|
return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(filePath) {
|
||||||
|
return filePath.replaceAll("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrailingNewline(value) {
|
||||||
|
return value.endsWith("\n") ? value : `${value}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCallback);
|
||||||
|
|
||||||
|
export const DEFAULT_CONTAINER_NAME = "truckwash-test-gateway";
|
||||||
|
export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
|
||||||
|
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
|
||||||
|
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||||
|
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||||
|
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
||||||
|
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||||
|
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
|
||||||
|
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
|
||||||
|
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"];
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
process.stdout.write(`Usage:
|
||||||
|
node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>]
|
||||||
|
node scripts/test-gateway.mjs stop [--container-name <name>]
|
||||||
|
node scripts/test-gateway.mjs logs [--container-name <name>] [--tail <lines>]
|
||||||
|
node scripts/test-gateway.mjs status [--container-name <name>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--install-token <token> Claim a new edge gateway before starting the container.
|
||||||
|
--container-name <name> Docker container name. Default: ${DEFAULT_CONTAINER_NAME}
|
||||||
|
--hostname <hostname> Gateway hostname reported during claim and Docker run.
|
||||||
|
--host-api-url <url> Host-reachable API URL for claim/health checks. Default: ${DEFAULT_HOST_API_URL}
|
||||||
|
--api-url <url> Container-internal API URL. Default: ${DEFAULT_CONTAINER_API_URL}
|
||||||
|
--broker-url <url> Container-internal broker URL. Default: ${DEFAULT_CONTAINER_BROKER_URL}
|
||||||
|
--config-dir <dir> Directory for generated config. Default: backend-php/.tmp/test-gateway
|
||||||
|
--image-tag <tag> Docker image tag. Default: ${DEFAULT_IMAGE_TAG}
|
||||||
|
--heartbeat-seconds <n> Agent heartbeat interval. Default: ${DEFAULT_HEARTBEAT_INTERVAL_SECONDS}
|
||||||
|
--tail <lines> Log lines for the logs action. Default: 200
|
||||||
|
--skip-compose-up Do not start the local Docker Compose stack before start.
|
||||||
|
--skip-build Do not rebuild the test gateway image before start.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveComposeProjectName(rootDir, env = process.env) {
|
||||||
|
const explicit = String(env.COMPOSE_PROJECT_NAME || "").trim();
|
||||||
|
if (explicit !== "") {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.basename(rootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveComposeNetworkName(rootDir, env = process.env) {
|
||||||
|
return `${resolveComposeProjectName(rootDir, env)}_default`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfigDirectory(rootDir, explicitDir = null) {
|
||||||
|
if (explicitDir) {
|
||||||
|
return path.resolve(rootDir, explicitDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(rootDir, ".tmp", "test-gateway");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
|
||||||
|
if (String(installToken || "").trim() !== "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(existingConfig.gatewayId && existingConfig.agentToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGatewayConfig({
|
||||||
|
existingConfig = {},
|
||||||
|
claim = null,
|
||||||
|
apiUrl = DEFAULT_CONTAINER_API_URL,
|
||||||
|
brokerUrl = DEFAULT_CONTAINER_BROKER_URL,
|
||||||
|
hostname = DEFAULT_CONTAINER_NAME,
|
||||||
|
heartbeatIntervalSeconds = DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
} = {}) {
|
||||||
|
const installedVersion = String(existingConfig.installedVersion || DEFAULT_INSTALLED_VERSION);
|
||||||
|
const targetVersion = String(existingConfig.targetVersion || installedVersion);
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiUrl,
|
||||||
|
brokerUrl,
|
||||||
|
gatewayId: claim?.gateway?.id ?? existingConfig.gatewayId ?? null,
|
||||||
|
agentToken: claim?.agent_token ?? existingConfig.agentToken ?? null,
|
||||||
|
hostname,
|
||||||
|
releaseChannel: claim?.release_channel ?? existingConfig.releaseChannel ?? "stable",
|
||||||
|
installedVersion,
|
||||||
|
targetVersion,
|
||||||
|
installDir: DEFAULT_INSTALL_DIR,
|
||||||
|
agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`,
|
||||||
|
serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`,
|
||||||
|
serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME),
|
||||||
|
restartMode: "spawn",
|
||||||
|
heartbeatIntervalSeconds,
|
||||||
|
commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20),
|
||||||
|
shellActionPollTimeoutSeconds: Number(existingConfig.shellActionPollTimeoutSeconds || 20),
|
||||||
|
commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 1000),
|
||||||
|
shellActionPollRetryDelayMs: Number(existingConfig.shellActionPollRetryDelayMs || 1000),
|
||||||
|
brokerReconnectDelayMs: Number(existingConfig.brokerReconnectDelayMs || 1500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArgs(argv = process.argv.slice(2)) {
|
||||||
|
const knownActions = new Set(["start", "stop", "logs", "status"]);
|
||||||
|
const [first = "", ...remaining] = argv;
|
||||||
|
const action = knownActions.has(first) ? first : "start";
|
||||||
|
const rest = knownActions.has(first) ? remaining : argv;
|
||||||
|
const options = {
|
||||||
|
action,
|
||||||
|
containerName: DEFAULT_CONTAINER_NAME,
|
||||||
|
imageTag: DEFAULT_IMAGE_TAG,
|
||||||
|
hostApiUrl: DEFAULT_HOST_API_URL,
|
||||||
|
apiUrl: DEFAULT_CONTAINER_API_URL,
|
||||||
|
brokerUrl: DEFAULT_CONTAINER_BROKER_URL,
|
||||||
|
hostname: DEFAULT_CONTAINER_NAME,
|
||||||
|
configDir: null,
|
||||||
|
installToken: "",
|
||||||
|
heartbeatSeconds: DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
tail: "200",
|
||||||
|
skipComposeUp: false,
|
||||||
|
skipBuild: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < rest.length; index += 1) {
|
||||||
|
const arg = rest[index];
|
||||||
|
const next = rest[index + 1];
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case "--container-name":
|
||||||
|
options.containerName = String(next || "").trim() || DEFAULT_CONTAINER_NAME;
|
||||||
|
options.hostname = options.containerName;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--hostname":
|
||||||
|
options.hostname = String(next || "").trim() || options.hostname;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--image-tag":
|
||||||
|
options.imageTag = String(next || "").trim() || DEFAULT_IMAGE_TAG;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--host-api-url":
|
||||||
|
options.hostApiUrl = String(next || "").trim() || DEFAULT_HOST_API_URL;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--api-url":
|
||||||
|
options.apiUrl = String(next || "").trim() || DEFAULT_CONTAINER_API_URL;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--broker-url":
|
||||||
|
options.brokerUrl = String(next || "").trim() || DEFAULT_CONTAINER_BROKER_URL;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--config-dir":
|
||||||
|
options.configDir = String(next || "").trim() || null;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--install-token":
|
||||||
|
options.installToken = String(next || "").trim();
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--heartbeat-seconds":
|
||||||
|
options.heartbeatSeconds = Number(next || DEFAULT_HEARTBEAT_INTERVAL_SECONDS);
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--tail":
|
||||||
|
options.tail = String(next || "200").trim() || "200";
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--skip-compose-up":
|
||||||
|
options.skipComposeUp = true;
|
||||||
|
break;
|
||||||
|
case "--skip-build":
|
||||||
|
options.skipBuild = true;
|
||||||
|
break;
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
options.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
|
||||||
|
if (stdio === "inherit") {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = spawnCallback(command, args, {
|
||||||
|
cwd,
|
||||||
|
stdio: "inherit",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code === 0 || allowFailure) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
|
||||||
|
});
|
||||||
|
child.on("error", reject);
|
||||||
|
});
|
||||||
|
return { stdout: "", stderr: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await execFile(command, args, {
|
||||||
|
cwd,
|
||||||
|
windowsHide: true,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (allowFailure) {
|
||||||
|
return {
|
||||||
|
stdout: error.stdout || "",
|
||||||
|
stderr: error.stderr || "",
|
||||||
|
code: error.code || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureComposeServices(rootDir, skipComposeUp) {
|
||||||
|
if (skipComposeUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runCommand("docker", ["compose", "up", "-d", ...DEFAULT_COMPOSE_SERVICES], {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url) {
|
||||||
|
return String(url || "").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForApiReady(hostApiUrl, attempts = 60) {
|
||||||
|
const baseUrl = normalizeBaseUrl(hostApiUrl);
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/ping`);
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = new Error(`HTTP ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`API did not become ready at ${baseUrl}/ping: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimGateway(hostApiUrl, installToken, hostname) {
|
||||||
|
const response = await fetch(`${normalizeBaseUrl(hostApiUrl)}/edge-agent/claim`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: installToken,
|
||||||
|
hostname,
|
||||||
|
installed_version: DEFAULT_INSTALLED_VERSION,
|
||||||
|
metadata: {
|
||||||
|
source: "docker-test-gateway-script",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(json?.data?.message || json?.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.data ?? json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfig(configFilePath) {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configFilePath, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeConfig(configFilePath, config) {
|
||||||
|
await fs.mkdir(path.dirname(configFilePath), { recursive: true });
|
||||||
|
await fs.writeFile(configFilePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDockerMountPath(hostPath) {
|
||||||
|
return hostPath.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImage(rootDir, imageTag, skipBuild) {
|
||||||
|
if (skipBuild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runCommand(
|
||||||
|
"docker",
|
||||||
|
["build", "-t", imageTag, "-f", "services/edge-agent/Dockerfile.test-gateway", "."],
|
||||||
|
{
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeContainer(containerName) {
|
||||||
|
await runCommand("docker", ["rm", "-f", containerName], { allowFailure: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startContainer({
|
||||||
|
rootDir,
|
||||||
|
imageTag,
|
||||||
|
containerName,
|
||||||
|
hostname,
|
||||||
|
configDir,
|
||||||
|
}) {
|
||||||
|
const networkName = resolveComposeNetworkName(rootDir);
|
||||||
|
const mountedConfigDir = toDockerMountPath(configDir);
|
||||||
|
|
||||||
|
await removeContainer(containerName);
|
||||||
|
await runCommand(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
containerName,
|
||||||
|
"--hostname",
|
||||||
|
hostname,
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
networkName,
|
||||||
|
"-v",
|
||||||
|
`${mountedConfigDir}:/config`,
|
||||||
|
imageTag,
|
||||||
|
"--config",
|
||||||
|
`/config/${DEFAULT_CONFIG_FILE_NAME}`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printStatus({ imageTag, configDir, containerName }) {
|
||||||
|
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||||
|
const config = await readConfig(configFilePath);
|
||||||
|
const inspection = await runCommand("docker", ["inspect", containerName], {
|
||||||
|
allowFailure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let container = null;
|
||||||
|
if (inspection.stdout && String(inspection.stdout).trim() !== "") {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(inspection.stdout);
|
||||||
|
container = Array.isArray(decoded) ? decoded[0] ?? null : null;
|
||||||
|
} catch {
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
containerName,
|
||||||
|
configFilePath,
|
||||||
|
gatewayId: config.gatewayId ?? null,
|
||||||
|
installDir: config.installDir ?? null,
|
||||||
|
agentPath: config.agentPath ?? null,
|
||||||
|
serviceUnitPath: config.serviceUnitPath ?? null,
|
||||||
|
containerStatus: container?.State?.Status ?? "missing",
|
||||||
|
running: Boolean(container?.State?.Running),
|
||||||
|
image: container?.Config?.Image ?? imageTag,
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs();
|
||||||
|
if (options.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptPath = fileURLToPath(import.meta.url);
|
||||||
|
const rootDir = path.resolve(path.dirname(scriptPath), "..");
|
||||||
|
const configDir = resolveConfigDirectory(rootDir, options.configDir);
|
||||||
|
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||||
|
|
||||||
|
switch (options.action) {
|
||||||
|
case "start": {
|
||||||
|
await ensureComposeServices(rootDir, options.skipComposeUp);
|
||||||
|
await waitForApiReady(options.hostApiUrl);
|
||||||
|
await ensureImage(rootDir, options.imageTag, options.skipBuild);
|
||||||
|
|
||||||
|
const existingConfig = await readConfig(configFilePath);
|
||||||
|
let claim = null;
|
||||||
|
if (shouldClaimGateway(existingConfig, options.installToken)) {
|
||||||
|
if (String(options.installToken || "").trim() === "") {
|
||||||
|
throw new Error(`An install token is required to create the first test gateway config at ${configFilePath}`);
|
||||||
|
}
|
||||||
|
claim = await claimGateway(options.hostApiUrl, options.installToken, options.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = buildGatewayConfig({
|
||||||
|
existingConfig,
|
||||||
|
claim,
|
||||||
|
apiUrl: options.apiUrl,
|
||||||
|
brokerUrl: options.brokerUrl,
|
||||||
|
hostname: options.hostname,
|
||||||
|
heartbeatIntervalSeconds: Number.isFinite(options.heartbeatSeconds) && options.heartbeatSeconds > 0
|
||||||
|
? Math.round(options.heartbeatSeconds)
|
||||||
|
: DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeConfig(configFilePath, config);
|
||||||
|
await startContainer({
|
||||||
|
rootDir,
|
||||||
|
imageTag: options.imageTag,
|
||||||
|
containerName: options.containerName,
|
||||||
|
hostname: options.hostname,
|
||||||
|
configDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdout.write(`Test gateway container started.
|
||||||
|
Container: ${options.containerName}
|
||||||
|
Config: ${configFilePath}
|
||||||
|
Gateway ID: ${config.gatewayId ?? "unclaimed"}
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "stop":
|
||||||
|
await removeContainer(options.containerName);
|
||||||
|
process.stdout.write(`Removed container ${options.containerName}\n`);
|
||||||
|
return;
|
||||||
|
case "logs":
|
||||||
|
await runCommand("docker", ["logs", "-f", "--tail", options.tail, options.containerName], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "status":
|
||||||
|
await printStatus({
|
||||||
|
imageTag: options.imageTag,
|
||||||
|
configDir,
|
||||||
|
containerName: options.containerName,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported action: ${options.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
|
const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
||||||
|
if (invokedScript === path.resolve(currentFilePath)) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
& node (Join-Path $scriptDir "test-gateway.mjs") @args
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
node "$SCRIPT_DIR/test-gateway.mjs" "$@"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_CONTAINER_NAME,
|
||||||
|
DEFAULT_CONTAINER_API_URL,
|
||||||
|
DEFAULT_CONTAINER_BROKER_URL,
|
||||||
|
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
buildGatewayConfig,
|
||||||
|
parseArgs,
|
||||||
|
resolveComposeNetworkName,
|
||||||
|
resolveComposeProjectName,
|
||||||
|
resolveConfigDirectory,
|
||||||
|
shouldClaimGateway,
|
||||||
|
} from "./test-gateway.mjs";
|
||||||
|
|
||||||
|
test("resolveComposeProjectName prefers COMPOSE_PROJECT_NAME", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveComposeProjectName("C:/Users/test/backend-php", { COMPOSE_PROJECT_NAME: "custom-stack" }),
|
||||||
|
"custom-stack"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveComposeProjectName falls back to backend directory name", () => {
|
||||||
|
assert.equal(resolveComposeProjectName("C:/Users/test/backend-php", {}), "backend-php");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveComposeNetworkName derives the default compose network", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveComposeNetworkName("C:/Users/test/backend-php", { COMPOSE_PROJECT_NAME: "custom-stack" }),
|
||||||
|
"custom-stack_default"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveConfigDirectory uses the default temp folder when none is provided", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveConfigDirectory("C:/Users/test/backend-php"),
|
||||||
|
"C:\\Users\\test\\backend-php\\.tmp\\test-gateway"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldClaimGateway requires a token when no credentials are present", () => {
|
||||||
|
assert.equal(shouldClaimGateway({}, ""), true);
|
||||||
|
assert.equal(shouldClaimGateway({ gatewayId: 12, agentToken: "secret" }, ""), false);
|
||||||
|
assert.equal(shouldClaimGateway({ gatewayId: 12, agentToken: "secret" }, "fresh-token"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs accepts help without an explicit action", () => {
|
||||||
|
const options = parseArgs(["--help"]);
|
||||||
|
|
||||||
|
assert.equal(options.action, "start");
|
||||||
|
assert.equal(options.help, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildGatewayConfig applies defaults for a dockerized gateway", () => {
|
||||||
|
const config = buildGatewayConfig({});
|
||||||
|
|
||||||
|
assert.equal(config.apiUrl, DEFAULT_CONTAINER_API_URL);
|
||||||
|
assert.equal(config.brokerUrl, DEFAULT_CONTAINER_BROKER_URL);
|
||||||
|
assert.equal(config.hostname, DEFAULT_CONTAINER_NAME);
|
||||||
|
assert.equal(config.restartMode, "spawn");
|
||||||
|
assert.equal(config.heartbeatIntervalSeconds, DEFAULT_HEARTBEAT_INTERVAL_SECONDS);
|
||||||
|
assert.equal(config.installDir, "/opt/truckwash-edge-agent");
|
||||||
|
assert.equal(config.agentPath, "/opt/truckwash-edge-agent/agent.php");
|
||||||
|
assert.equal(config.serviceUnitPath, "/opt/truckwash-edge-agent/truckwash-edge-agent.service");
|
||||||
|
assert.equal(config.serviceName, DEFAULT_CONTAINER_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildGatewayConfig folds claim response into the generated config", () => {
|
||||||
|
const config = buildGatewayConfig({
|
||||||
|
existingConfig: {
|
||||||
|
installedVersion: "0.9.0",
|
||||||
|
targetVersion: "0.9.1",
|
||||||
|
},
|
||||||
|
hostname: "gw-docker-1",
|
||||||
|
claim: {
|
||||||
|
gateway: { id: 44 },
|
||||||
|
agent_token: "agent-secret",
|
||||||
|
release_channel: "stable",
|
||||||
|
},
|
||||||
|
heartbeatIntervalSeconds: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(config.gatewayId, 44);
|
||||||
|
assert.equal(config.agentToken, "agent-secret");
|
||||||
|
assert.equal(config.releaseChannel, "stable");
|
||||||
|
assert.equal(config.hostname, "gw-docker-1");
|
||||||
|
assert.equal(config.installedVersion, "0.9.0");
|
||||||
|
assert.equal(config.targetVersion, "0.9.1");
|
||||||
|
assert.equal(config.heartbeatIntervalSeconds, 7);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM php:8.2-cli
|
||||||
|
|
||||||
|
WORKDIR /opt/truckwash-edge-agent
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
ca-certificates; \
|
||||||
|
docker-php-ext-install curl; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY services/nginx/app/resources/edge-gateway-agent/ ./
|
||||||
|
|
||||||
|
ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"]
|
||||||
|
CMD ["--config", "/config/test-gateway.json"]
|
||||||
Vendored
+9
-34
@@ -137,7 +137,6 @@ function buildTransportHeartbeatState(brokerState = {}) {
|
|||||||
status: "ONLINE",
|
status: "ONLINE",
|
||||||
metadata: {
|
metadata: {
|
||||||
command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
|
command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
|
||||||
shell_transport: "API_POLLING",
|
|
||||||
broker_connected: brokerConnected,
|
broker_connected: brokerConnected,
|
||||||
broker_url: brokerState.url || null,
|
broker_url: brokerState.url || null,
|
||||||
broker_last_error: brokerState.lastError || null,
|
broker_last_error: brokerState.lastError || null,
|
||||||
@@ -1763,20 +1762,21 @@ export async function startAgent({
|
|||||||
config.configPath = configPath;
|
config.configPath = configPath;
|
||||||
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
|
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
|
||||||
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
|
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
|
||||||
const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20);
|
|
||||||
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
|
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
|
||||||
const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000);
|
|
||||||
const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS);
|
const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS);
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let cpuSnapshot = null;
|
let cpuSnapshot = null;
|
||||||
let lastHeartbeatLatencyMs = null;
|
let lastHeartbeatLatencyMs = null;
|
||||||
const shellEventPublisher = createShellEventPublisher(config, fetchImpl);
|
void createShellBridgeImpl;
|
||||||
let brokerBridge = null;
|
let brokerBridge = null;
|
||||||
const shell = createShellBridgeImpl((message) => {
|
const shell = {
|
||||||
shellEventPublisher.publish(message);
|
open: async () => {},
|
||||||
brokerBridge?.send(message);
|
input: () => {},
|
||||||
});
|
resize: () => {},
|
||||||
|
close: () => {},
|
||||||
|
dispose: () => {},
|
||||||
|
};
|
||||||
brokerBridge = createBrokerBridge({
|
brokerBridge = createBrokerBridge({
|
||||||
config,
|
config,
|
||||||
shell,
|
shell,
|
||||||
@@ -1841,32 +1841,8 @@ export async function startAgent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const runShellActionPollLoop = async () => {
|
|
||||||
while (!stopped) {
|
|
||||||
try {
|
|
||||||
const action = await pollShellActionJob(config, fetchImpl, shellActionPollTimeoutSeconds);
|
|
||||||
if (stopped) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!action) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processPolledShellAction(config, action, shell, fetchImpl);
|
|
||||||
} catch {
|
|
||||||
if (stopped) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await sendTransportHeartbeat();
|
await sendTransportHeartbeat();
|
||||||
const commandPollPromise = runCommandPollLoop();
|
const commandPollPromise = runCommandPollLoop();
|
||||||
const shellActionPollPromise = runShellActionPollLoop();
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
sendTransportHeartbeat().catch(() => {});
|
sendTransportHeartbeat().catch(() => {});
|
||||||
@@ -1878,8 +1854,7 @@ export async function startAgent({
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
brokerBridge?.stop();
|
brokerBridge?.stop();
|
||||||
shell.dispose();
|
shell.dispose();
|
||||||
await shellEventPublisher.drain().catch(() => {});
|
await Promise.allSettled([commandPollPromise]);
|
||||||
await Promise.allSettled([commandPollPromise, shellActionPollPromise]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@ use interfaces\economic_i;
|
|||||||
|
|
||||||
class economic implements economic_i
|
class economic implements economic_i
|
||||||
{
|
{
|
||||||
|
public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration of the economic module
|
* Configuration of the economic module
|
||||||
* @var economic_c
|
* @var economic_c
|
||||||
@@ -120,6 +122,37 @@ class economic implements economic_i
|
|||||||
return new $this->helpers->economic_tasks();
|
return new $this->helpers->economic_tasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTransactionDraftCustomerNumber(): ?int
|
||||||
|
{
|
||||||
|
$value = $this->config->transaction_draft_customer_number->getVariableValue();
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer_number = (int)$value;
|
||||||
|
return $customer_number > 0 ? $customer_number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDraftCustomerNumber(?int $customer_number): bool
|
||||||
|
{
|
||||||
|
$configured_customer_number = $this->getTransactionDraftCustomerNumber();
|
||||||
|
if ($configured_customer_number === null || $customer_number === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configured_customer_number === (int)$customer_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function assertCustomerNumberIsNotDraft(?int $customer_number): void
|
||||||
|
{
|
||||||
|
if ($this->isDraftCustomerNumber($customer_number)) {
|
||||||
|
throw new \Exception(self::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a customer in e-conomic and return the raw upstream payload.
|
* Create a customer in e-conomic and return the raw upstream payload.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class economic_transfer_executor
|
|||||||
*/
|
*/
|
||||||
public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array
|
public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array
|
||||||
{
|
{
|
||||||
|
$economic = new economic();
|
||||||
$order = (new orders_o())->getOrderById($order_id);
|
$order = (new orders_o())->getOrderById($order_id);
|
||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
throw new Exception('Order not found');
|
throw new Exception('Order not found');
|
||||||
@@ -44,6 +45,7 @@ class economic_transfer_executor
|
|||||||
if (!$customer->exists()) {
|
if (!$customer->exists()) {
|
||||||
throw new Exception('Customer not found');
|
throw new Exception('Customer not found');
|
||||||
}
|
}
|
||||||
|
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
|
||||||
|
|
||||||
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
|
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
|
||||||
$economic_invoice_draft = new economic_invoice_draft_mo();
|
$economic_invoice_draft = new economic_invoice_draft_mo();
|
||||||
@@ -128,6 +130,7 @@ class economic_transfer_executor
|
|||||||
*/
|
*/
|
||||||
public function exportOrderInvoice(int $order_id, int $user_id = 0): array
|
public function exportOrderInvoice(int $order_id, int $user_id = 0): array
|
||||||
{
|
{
|
||||||
|
$economic = new economic();
|
||||||
$order = (new orders_o())->getOrderById($order_id);
|
$order = (new orders_o())->getOrderById($order_id);
|
||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
throw new Exception('Order not found');
|
throw new Exception('Order not found');
|
||||||
@@ -137,6 +140,7 @@ class economic_transfer_executor
|
|||||||
if (!$customer->exists()) {
|
if (!$customer->exists()) {
|
||||||
throw new Exception('Customer not found');
|
throw new Exception('Customer not found');
|
||||||
}
|
}
|
||||||
|
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
|
||||||
|
|
||||||
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
|
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
|
||||||
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
|
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
|
||||||
@@ -189,6 +193,7 @@ class economic_transfer_executor
|
|||||||
{
|
{
|
||||||
$collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id);
|
$collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id);
|
||||||
$collected_order_invoices->requireSelected();
|
$collected_order_invoices->requireSelected();
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
|
||||||
|
|
||||||
if ($collected_order_invoices->external_id->value() === null) {
|
if ($collected_order_invoices->external_id->value() === null) {
|
||||||
if (!$send_as_is) {
|
if (!$send_as_is) {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class edge_gateway_agent_artifact_locator
|
||||||
|
{
|
||||||
|
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
|
||||||
|
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
||||||
|
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function candidatePaths(
|
||||||
|
string $fileName,
|
||||||
|
?string $basePath = null,
|
||||||
|
?string $mountedArtifactDirectory = null,
|
||||||
|
?string $bakedArtifactDirectory = null
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
$basePath = self::normalizePath($basePath ?? WD);
|
||||||
|
$candidateDirectories = [];
|
||||||
|
|
||||||
|
$configuredDirectory = trim((string)(getenv('EDGE_AGENT_ARTIFACT_DIR') ?: ''));
|
||||||
|
if ($configuredDirectory !== '') {
|
||||||
|
$candidateDirectories[] = self::normalizePath($configuredDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
||||||
|
|
||||||
|
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
||||||
|
if ($mountedArtifactDirectory !== null) {
|
||||||
|
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bakedArtifactDirectory = $bakedArtifactDirectory ?? self::bakedArtifactDirectory();
|
||||||
|
if ($bakedArtifactDirectory !== null) {
|
||||||
|
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||||
|
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||||
|
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||||
|
|
||||||
|
$paths = [];
|
||||||
|
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
||||||
|
$paths[] = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($paths));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function resolve(
|
||||||
|
string $fileName,
|
||||||
|
?string $basePath = null,
|
||||||
|
?string $mountedArtifactDirectory = null,
|
||||||
|
?string $bakedArtifactDirectory = null
|
||||||
|
): string
|
||||||
|
{
|
||||||
|
$candidatePaths = self::candidatePaths($fileName, $basePath, $mountedArtifactDirectory, $bakedArtifactDirectory);
|
||||||
|
foreach ($candidatePaths as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = 'Missing edge agent artifact: ' . $fileName . '.';
|
||||||
|
$message .= ' Checked paths: ' . implode(', ', $candidatePaths) . '.';
|
||||||
|
$message .= ' Deploy the router-owned artifacts under ' . self::routerArtifactDirectory($basePath) . '.';
|
||||||
|
|
||||||
|
$legacyNodeArtifact = self::legacyNodeArtifactPath($basePath);
|
||||||
|
if ($legacyNodeArtifact !== null) {
|
||||||
|
$message .= ' Legacy Node dist artifact found at ' . $legacyNodeArtifact . '.';
|
||||||
|
$message .= ' Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizePath(string $path): string
|
||||||
|
{
|
||||||
|
$normalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
|
||||||
|
if (DIRECTORY_SEPARATOR === '/') {
|
||||||
|
$normalized = preg_replace('#/+#', '/', $normalized) ?: $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = rtrim($normalized, DIRECTORY_SEPARATOR);
|
||||||
|
if ($trimmed === '' && str_starts_with($normalized, DIRECTORY_SEPARATOR)) {
|
||||||
|
return DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function routerArtifactDirectory(string $basePath): string
|
||||||
|
{
|
||||||
|
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mountedArtifactDirectory(): ?string
|
||||||
|
{
|
||||||
|
if (DIRECTORY_SEPARATOR !== '/') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::DEFAULT_MOUNTED_ARTIFACT_DIRECTORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function bakedArtifactDirectory(): ?string
|
||||||
|
{
|
||||||
|
if (DIRECTORY_SEPARATOR !== '/') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::DEFAULT_BAKED_ARTIFACT_DIRECTORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function legacyNodeArtifactPath(?string $basePath = null): ?string
|
||||||
|
{
|
||||||
|
$basePath = self::normalizePath($basePath ?? WD);
|
||||||
|
$candidateDirectories = [
|
||||||
|
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
|
||||||
|
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
|
||||||
|
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (DIRECTORY_SEPARATOR === '/') {
|
||||||
|
$candidateDirectories[] = '/services/edge-agent/dist';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_values(array_unique(array_map(static fn(string $directory): string => self::normalizePath($directory), $candidateDirectories))) as $directory) {
|
||||||
|
$path = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'agent.mjs';
|
||||||
|
if (is_file($path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class edge_gateway_install_service
|
||||||
|
{
|
||||||
|
private const ARTIFACTS = [
|
||||||
|
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||||
|
'truckwash-edge-agent.service' => 'text/plain; charset=utf-8',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly ?edge_gateway_manager $manager = null)
|
||||||
|
{
|
||||||
|
edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildInstallScript(string $plainToken): string
|
||||||
|
{
|
||||||
|
return $this->manager()->buildInstallScript($plainToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function verifyInstallToken(string $plainToken): array
|
||||||
|
{
|
||||||
|
return $this->manager()->verifyInstallToken($plainToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function readArtifact(string $fileName): string
|
||||||
|
{
|
||||||
|
$path = $this->artifactPath($fileName);
|
||||||
|
$contents = file_get_contents($path);
|
||||||
|
if ($contents === false) {
|
||||||
|
throw new Exception('Unable to read edge agent artifact');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contentType(string $fileName): string
|
||||||
|
{
|
||||||
|
return self::ARTIFACTS[$fileName] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildArtifactUrl(string $fileName): string
|
||||||
|
{
|
||||||
|
return rtrim($this->manager()->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function artifactPath(string $fileName): string
|
||||||
|
{
|
||||||
|
if (!array_key_exists($fileName, self::ARTIFACTS)) {
|
||||||
|
throw new Exception('Unknown edge agent artifact');
|
||||||
|
}
|
||||||
|
|
||||||
|
return edge_gateway_agent_artifact_locator::resolve($fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manager(): edge_gateway_manager
|
||||||
|
{
|
||||||
|
return $this->manager ?? new edge_gateway_manager();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class edge_gateway_operation_exception extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
public readonly string $errorCode,
|
||||||
|
public readonly int $status = 400
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use objects\edge_gateway_operation_events_o;
|
||||||
|
use objects\edge_gateway_operations_o;
|
||||||
|
use objects\edge_gateways_o;
|
||||||
|
|
||||||
|
class edge_gateway_operation_service
|
||||||
|
{
|
||||||
|
public const TYPE_DISCOVERY = 'DISCOVERY';
|
||||||
|
public const TYPE_UPDATE = 'UPDATE';
|
||||||
|
public const TYPE_UNINSTALL = 'UNINSTALL';
|
||||||
|
|
||||||
|
public const STATUS_PENDING = 'PENDING';
|
||||||
|
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
|
||||||
|
public const STATUS_COMPLETED = 'COMPLETED';
|
||||||
|
public const STATUS_FAILED = 'FAILED';
|
||||||
|
|
||||||
|
public const LEVEL_INFO = 'INFO';
|
||||||
|
public const LEVEL_WARNING = 'WARNING';
|
||||||
|
public const LEVEL_ERROR = 'ERROR';
|
||||||
|
|
||||||
|
public const ERROR_OFFLINE = 'EDGE_GATEWAY_OFFLINE';
|
||||||
|
public const ERROR_STALE_HEARTBEAT = 'EDGE_GATEWAY_STALE_HEARTBEAT';
|
||||||
|
public const ERROR_INVALID_TOKEN = 'EDGE_GATEWAY_INVALID_TOKEN';
|
||||||
|
public const ERROR_OPERATION_TIMEOUT = 'EDGE_GATEWAY_OPERATION_TIMEOUT';
|
||||||
|
public const ERROR_UNSUPPORTED_VERSION = 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
|
||||||
|
public const ERROR_CONFLICT = 'EDGE_GATEWAY_CONFLICT';
|
||||||
|
public const ERROR_VALIDATION = 'EDGE_GATEWAY_VALIDATION_FAILED';
|
||||||
|
|
||||||
|
public const POLL_INTERVAL_MICROSECONDS = 250000;
|
||||||
|
public const OPERATION_TIMEOUT_SECONDS = 900;
|
||||||
|
public const OPERATION_LEASE_SECONDS = 45;
|
||||||
|
|
||||||
|
public function __construct(private readonly ?edge_gateway_manager $manager = null)
|
||||||
|
{
|
||||||
|
edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function listOperations(int $gatewayId, int $limit = 20, bool $includeEvents = true): array
|
||||||
|
{
|
||||||
|
$this->requireGateway($gatewayId);
|
||||||
|
$this->failTimedOutOperations($gatewayId);
|
||||||
|
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'deleted_at' => null,
|
||||||
|
], ['id']);
|
||||||
|
|
||||||
|
$ids = array_map(static fn(array $row): int => (int)$row['id'], $rows);
|
||||||
|
rsort($ids);
|
||||||
|
$ids = array_slice($ids, 0, max(1, $limit));
|
||||||
|
|
||||||
|
$operations = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$operations[] = $this->serializeOperation((new edge_gateway_operations_o())->select($id), $includeEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function listOperationEvents(int $gatewayId, int $operationId, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$this->requireOperation($gatewayId, $operationId);
|
||||||
|
|
||||||
|
$rows = (new edge_gateway_operation_events_o())->getFieldsWhere([
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
], ['id']);
|
||||||
|
|
||||||
|
$ids = array_map(static fn(array $row): int => (int)$row['id'], $rows);
|
||||||
|
sort($ids);
|
||||||
|
$ids = array_slice($ids, max(0, count($ids) - max(1, $limit)));
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$events[] = (new edge_gateway_operation_events_o())->select($id)->asArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function getActiveOperation(int $gatewayId, bool $includeEvents = true): ?array
|
||||||
|
{
|
||||||
|
$this->requireGateway($gatewayId);
|
||||||
|
$this->failTimedOutOperations($gatewayId);
|
||||||
|
|
||||||
|
$statement = db::getPDO()->prepare(
|
||||||
|
"SELECT id
|
||||||
|
FROM edge_gateway_operations
|
||||||
|
WHERE gateway_id = :gateway_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
ORDER BY FIELD(status, 'IN_PROGRESS', 'PENDING'), id ASC
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$statement->execute([':gateway_id' => $gatewayId]);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
|
||||||
|
if (!is_array($row) || empty($row['id'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->serializeOperation((new edge_gateway_operations_o())->select((int)$row['id']), $includeEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function buildRecentOperationsSummary(int $gatewayId): array
|
||||||
|
{
|
||||||
|
$operations = $this->listOperations($gatewayId, 25, false);
|
||||||
|
$summary = [
|
||||||
|
'total' => count($operations),
|
||||||
|
'pending' => 0,
|
||||||
|
'in_progress' => 0,
|
||||||
|
'completed' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'latest_completed_at' => null,
|
||||||
|
'latest_failed_at' => null,
|
||||||
|
'latest_type' => $operations[0]['type'] ?? null,
|
||||||
|
'latest_status' => $operations[0]['status'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($operations as $operation) {
|
||||||
|
$status = (string)($operation['status'] ?? self::STATUS_PENDING);
|
||||||
|
if ($status === self::STATUS_PENDING) {
|
||||||
|
$summary['pending'] += 1;
|
||||||
|
} elseif ($status === self::STATUS_IN_PROGRESS) {
|
||||||
|
$summary['in_progress'] += 1;
|
||||||
|
} elseif ($status === self::STATUS_COMPLETED) {
|
||||||
|
$summary['completed'] += 1;
|
||||||
|
$summary['latest_completed_at'] ??= $operation['completed_at'] ?? null;
|
||||||
|
} elseif ($status === self::STATUS_FAILED) {
|
||||||
|
$summary['failed'] += 1;
|
||||||
|
$summary['latest_failed_at'] ??= $operation['completed_at'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function queueOperation(int $gatewayId, string $type, array $request = [], ?int $requestedBy = null): array
|
||||||
|
{
|
||||||
|
$gateway = $this->requireGateway($gatewayId);
|
||||||
|
$type = self::normalizeOperationType($type);
|
||||||
|
$request = $this->validateOperationRequest($gateway, $type, $request);
|
||||||
|
|
||||||
|
if ($this->getActiveOperation($gatewayId) !== null) {
|
||||||
|
throw new edge_gateway_operation_exception(
|
||||||
|
'Another gateway operation is already active',
|
||||||
|
self::ERROR_CONFLICT,
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === self::TYPE_DISCOVERY) {
|
||||||
|
$gateway->discovery_status->set('PENDING');
|
||||||
|
} elseif ($type === self::TYPE_UPDATE) {
|
||||||
|
$targetVersion = trim((string)($request['target_version'] ?? ''));
|
||||||
|
if ($targetVersion !== '') {
|
||||||
|
$gateway->target_version->set($targetVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'label' => match ($type) {
|
||||||
|
self::TYPE_DISCOVERY => 'Discovery queued',
|
||||||
|
self::TYPE_UPDATE => 'Update queued',
|
||||||
|
self::TYPE_UNINSTALL => 'Uninstall queued',
|
||||||
|
},
|
||||||
|
'progress' => 0,
|
||||||
|
'retryable' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
$operationId = (new edge_gateway_operations_o())->add_object([
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'type' => $type,
|
||||||
|
'operation_type' => $type,
|
||||||
|
'status' => self::STATUS_PENDING,
|
||||||
|
'request_json' => $request,
|
||||||
|
'summary_json' => $summary,
|
||||||
|
'result_json' => [],
|
||||||
|
'error_code' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
'correlation_id' => bin2hex(random_bytes(16)),
|
||||||
|
'agent_instance_id' => null,
|
||||||
|
'lease_expires_at' => null,
|
||||||
|
'last_progress_at' => null,
|
||||||
|
'attempt_count' => 0,
|
||||||
|
'requested_by' => $requestedBy,
|
||||||
|
'requested_at' => $this->now(),
|
||||||
|
'started_at' => null,
|
||||||
|
'completed_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->appendEventRecord(
|
||||||
|
$gatewayId,
|
||||||
|
$operationId,
|
||||||
|
self::LEVEL_INFO,
|
||||||
|
'OPERATION_QUEUED',
|
||||||
|
'Operation queued for gateway execution',
|
||||||
|
['type' => $type, 'request' => $request]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->manager()->logGatewayAudit(
|
||||||
|
$gatewayId,
|
||||||
|
(int)$gateway->department_id->value(),
|
||||||
|
'GATEWAY_OPERATION_QUEUED',
|
||||||
|
$requestedBy,
|
||||||
|
['operation_id' => $operationId, 'type' => $type]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function queueDiscoveryOperation(int $gatewayId, ?int $requestedBy = null): array
|
||||||
|
{
|
||||||
|
return $this->queueOperation($gatewayId, self::TYPE_DISCOVERY, [], $requestedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function rotateCredentials(int $gatewayId, ?int $userId = null): array
|
||||||
|
{
|
||||||
|
return $this->manager()->rotateGatewayCredentials($gatewayId, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function claimNextOperation(
|
||||||
|
int $gatewayId,
|
||||||
|
string $plainToken,
|
||||||
|
int $waitSeconds = edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS,
|
||||||
|
?string $agentInstanceId = null
|
||||||
|
): ?array {
|
||||||
|
try {
|
||||||
|
$this->manager()->authenticateGateway($gatewayId, $plainToken);
|
||||||
|
} catch (Exception) {
|
||||||
|
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deadline = microtime(true) + max(0, $waitSeconds);
|
||||||
|
do {
|
||||||
|
$this->failTimedOutOperations($gatewayId);
|
||||||
|
$operation = $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId));
|
||||||
|
if ($operation !== null) {
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (microtime(true) >= $deadline) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(self::POLL_INTERVAL_MICROSECONDS);
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function appendAgentOperationEvent(int $gatewayId, int $operationId, string $plainToken, array $payload): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->manager()->authenticateGateway($gatewayId, $plainToken);
|
||||||
|
} catch (Exception) {
|
||||||
|
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||||
|
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
|
||||||
|
return $this->serializeOperation($operation, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
|
||||||
|
if (!in_array($level, [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR], true)) {
|
||||||
|
$level = self::LEVEL_INFO;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = trim((string)($payload['message'] ?? 'Operation event received'));
|
||||||
|
if ($message === '') {
|
||||||
|
$message = 'Operation event received';
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = isset($payload['code']) ? trim((string)$payload['code']) : null;
|
||||||
|
$context = isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : [];
|
||||||
|
$this->appendEventRecord($gatewayId, $operationId, $level, $code, $message, $context);
|
||||||
|
|
||||||
|
$summary = (array)($operation->summary_json->value() ?? []);
|
||||||
|
$summary['last_event_at'] = $this->now();
|
||||||
|
$summary['last_event_message'] = $message;
|
||||||
|
if (isset($context['progress'])) {
|
||||||
|
$summary['progress'] = max(0, min(100, (int)$context['progress']));
|
||||||
|
}
|
||||||
|
if (isset($context['label']) && trim((string)$context['label']) !== '') {
|
||||||
|
$summary['label'] = trim((string)$context['label']);
|
||||||
|
}
|
||||||
|
$operation->summary_json->set($summary);
|
||||||
|
$this->refreshOperationLease($operation);
|
||||||
|
|
||||||
|
return $this->serializeOperation($operation, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function completeAgentOperation(int $gatewayId, int $operationId, string $plainToken, array $payload): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->manager()->authenticateGateway($gatewayId, $plainToken);
|
||||||
|
} catch (Exception) {
|
||||||
|
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||||
|
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
|
||||||
|
return $this->serializeOperation($operation, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = (bool)($payload['ok'] ?? false);
|
||||||
|
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
|
||||||
|
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
|
||||||
|
$errorCode = trim((string)($payload['error_code'] ?? ''));
|
||||||
|
if (!$ok && $errorCode === '') {
|
||||||
|
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
|
||||||
|
}
|
||||||
|
if (!$ok && $errorMessage === '') {
|
||||||
|
$errorMessage = 'Gateway operation failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation->status->set($ok ? self::STATUS_COMPLETED : self::STATUS_FAILED);
|
||||||
|
$operation->result_json->set($result);
|
||||||
|
$operation->error_code->set($ok ? null : $errorCode);
|
||||||
|
$operation->error_message->set($ok ? null : $errorMessage);
|
||||||
|
$operation->completed_at->set($this->now());
|
||||||
|
$operation->lease_expires_at->set(null);
|
||||||
|
$operation->last_progress_at->set($this->now());
|
||||||
|
|
||||||
|
$summary = (array)($operation->summary_json->value() ?? []);
|
||||||
|
$summary['label'] = $ok ? 'Completed' : 'Failed';
|
||||||
|
$summary['progress'] = 100;
|
||||||
|
$summary['retryable'] = !$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION;
|
||||||
|
$operation->summary_json->set($summary);
|
||||||
|
|
||||||
|
$this->appendEventRecord(
|
||||||
|
$gatewayId,
|
||||||
|
$operationId,
|
||||||
|
$ok ? self::LEVEL_INFO : self::LEVEL_ERROR,
|
||||||
|
$ok ? 'OPERATION_COMPLETED' : $errorCode,
|
||||||
|
$ok ? 'Operation completed successfully' : $errorMessage,
|
||||||
|
$result
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage);
|
||||||
|
|
||||||
|
return $this->serializeOperation($operation, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeOperationType(string $type): string
|
||||||
|
{
|
||||||
|
$normalized = strtoupper(trim($type));
|
||||||
|
if (in_array($normalized, [self::TYPE_DISCOVERY, self::TYPE_UPDATE, self::TYPE_UNINSTALL], true)) {
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new edge_gateway_operation_exception(
|
||||||
|
'Unsupported gateway operation type',
|
||||||
|
self::ERROR_VALIDATION,
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function validateOperationRequest(edge_gateways_o $gateway, string $type, array $request): array
|
||||||
|
{
|
||||||
|
if ($type === self::TYPE_DISCOVERY) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === self::TYPE_UPDATE) {
|
||||||
|
$targetVersion = trim((string)($request['target_version'] ?? ''));
|
||||||
|
if ($targetVersion === '') {
|
||||||
|
throw new edge_gateway_operation_exception(
|
||||||
|
'Update operations require target_version',
|
||||||
|
self::ERROR_VALIDATION,
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseChannel = trim((string)($request['release_channel'] ?? $gateway->release_channel->value() ?? edge_gateway_manager::DEFAULT_RELEASE_CHANNEL));
|
||||||
|
if ($releaseChannel === '') {
|
||||||
|
$releaseChannel = edge_gateway_manager::DEFAULT_RELEASE_CHANNEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge(
|
||||||
|
$this->manager()->buildUpdateOperationRequest($targetVersion, $releaseChannel),
|
||||||
|
$request,
|
||||||
|
[
|
||||||
|
'target_version' => $targetVersion,
|
||||||
|
'release_channel' => $releaseChannel,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === self::TYPE_UNINSTALL) {
|
||||||
|
return array_merge([
|
||||||
|
'service_name' => edge_gateway_manager::DEFAULT_AGENT_SERVICE_NAME,
|
||||||
|
'install_dir' => '/opt/truckwash-edge-agent',
|
||||||
|
], $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function claimPendingOperation(int $gatewayId, string $agentInstanceId): ?array
|
||||||
|
{
|
||||||
|
$pdo = db::getPDO();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$statement = $pdo->prepare(
|
||||||
|
"SELECT id
|
||||||
|
FROM edge_gateway_operations
|
||||||
|
WHERE gateway_id = :gateway_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status = 'PENDING'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE"
|
||||||
|
);
|
||||||
|
$statement->execute([':gateway_id' => $gatewayId]);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
|
||||||
|
if (!is_array($row) || empty($row['id'])) {
|
||||||
|
$pdo->commit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
|
||||||
|
$operation->status->set(self::STATUS_IN_PROGRESS);
|
||||||
|
$operation->started_at->set($this->now());
|
||||||
|
$operation->agent_instance_id->set($agentInstanceId);
|
||||||
|
$operation->last_progress_at->set($this->now());
|
||||||
|
$operation->lease_expires_at->set($this->leaseExpiry());
|
||||||
|
$operation->attempt_count->set(((int)($operation->attempt_count->value() ?? 0)) + 1);
|
||||||
|
$summary = (array)($operation->summary_json->value() ?? []);
|
||||||
|
$summary['label'] = 'Gateway is processing the operation';
|
||||||
|
$summary['progress'] = max(5, (int)($summary['progress'] ?? 0));
|
||||||
|
$summary['claimed_by'] = $agentInstanceId;
|
||||||
|
$operation->summary_json->set($summary);
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
$this->appendEventRecord(
|
||||||
|
$gatewayId,
|
||||||
|
(int)$operation->id,
|
||||||
|
self::LEVEL_INFO,
|
||||||
|
'OPERATION_STARTED',
|
||||||
|
'Gateway started processing the operation',
|
||||||
|
[
|
||||||
|
'type' => (string)$operation->type->value(),
|
||||||
|
'agent_instance_id' => $agentInstanceId,
|
||||||
|
'attempt_count' => (int)($operation->attempt_count->value() ?? 1),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->serializeOperation($operation, true);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function failTimedOutOperations(int $gatewayId): void
|
||||||
|
{
|
||||||
|
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'status' => self::STATUS_IN_PROGRESS,
|
||||||
|
'deleted_at' => null,
|
||||||
|
], ['id']);
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
|
||||||
|
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value());
|
||||||
|
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value());
|
||||||
|
$timedOut = $startedAt !== false
|
||||||
|
&& $startedAt !== null
|
||||||
|
&& ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS;
|
||||||
|
$leaseExpired = $leaseExpiresAt !== false
|
||||||
|
&& $leaseExpiresAt !== null
|
||||||
|
&& $leaseExpiresAt <= $now;
|
||||||
|
|
||||||
|
if (!$timedOut && !$leaseExpired) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errorMessage = $leaseExpired
|
||||||
|
? 'Gateway stopped reporting operation progress before the lease expired'
|
||||||
|
: 'Gateway operation timed out';
|
||||||
|
$summaryLabel = $leaseExpired ? 'Lease expired' : 'Timed out';
|
||||||
|
$operation->status->set(self::STATUS_FAILED);
|
||||||
|
$operation->completed_at->set($this->now());
|
||||||
|
$operation->error_code->set(self::ERROR_OPERATION_TIMEOUT);
|
||||||
|
$operation->error_message->set($errorMessage);
|
||||||
|
$operation->lease_expires_at->set(null);
|
||||||
|
$summary = (array)($operation->summary_json->value() ?? []);
|
||||||
|
$summary['label'] = $summaryLabel;
|
||||||
|
$summary['retryable'] = true;
|
||||||
|
$operation->summary_json->set($summary);
|
||||||
|
|
||||||
|
$this->appendEventRecord(
|
||||||
|
$gatewayId,
|
||||||
|
(int)$operation->id,
|
||||||
|
self::LEVEL_ERROR,
|
||||||
|
self::ERROR_OPERATION_TIMEOUT,
|
||||||
|
$errorMessage,
|
||||||
|
[
|
||||||
|
'agent_instance_id' => $operation->agent_instance_id->value(),
|
||||||
|
'last_progress_at' => $operation->last_progress_at->value(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function applyCompletionSideEffects(
|
||||||
|
int $gatewayId,
|
||||||
|
edge_gateway_operations_o $operation,
|
||||||
|
bool $ok,
|
||||||
|
array $result,
|
||||||
|
string $errorCode,
|
||||||
|
string $errorMessage
|
||||||
|
): void {
|
||||||
|
$gateway = $this->requireGateway($gatewayId);
|
||||||
|
$metadata = (array)($gateway->metadata_json->value() ?? []);
|
||||||
|
$type = (string)$operation->type->value();
|
||||||
|
$now = $this->now();
|
||||||
|
|
||||||
|
$metadata['last_operation'] = [
|
||||||
|
'id' => (int)$operation->id,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED,
|
||||||
|
'completed_at' => $now,
|
||||||
|
'error_code' => $ok ? null : $errorCode,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($type === self::TYPE_DISCOVERY) {
|
||||||
|
if ($ok) {
|
||||||
|
$inventory = isset($result['inventory']) && is_array($result['inventory']) ? (array)$result['inventory'] : [];
|
||||||
|
$this->manager()->syncGatewayInventory($gatewayId, $inventory);
|
||||||
|
$gateway->discovery_status->set('READY');
|
||||||
|
$metadata['last_discovery_completed_at'] = $now;
|
||||||
|
$metadata['last_discovery_error'] = null;
|
||||||
|
} else {
|
||||||
|
$gateway->discovery_status->set('FAILED');
|
||||||
|
$metadata['last_discovery_error'] = [
|
||||||
|
'code' => $errorCode,
|
||||||
|
'message' => $errorMessage,
|
||||||
|
'at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} elseif ($type === self::TYPE_UPDATE) {
|
||||||
|
if ($ok) {
|
||||||
|
$installedVersion = trim((string)($result['installed_version'] ?? $result['target_version'] ?? $operation->request_json->value()['target_version'] ?? ''));
|
||||||
|
if ($installedVersion !== '') {
|
||||||
|
$gateway->installed_version->set($installedVersion);
|
||||||
|
$gateway->target_version->set($installedVersion);
|
||||||
|
}
|
||||||
|
$metadata['last_update_completed_at'] = $now;
|
||||||
|
$metadata['last_update_error'] = null;
|
||||||
|
} else {
|
||||||
|
$metadata['last_update_error'] = [
|
||||||
|
'code' => $errorCode,
|
||||||
|
'message' => $errorMessage,
|
||||||
|
'at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} elseif ($type === self::TYPE_UNINSTALL) {
|
||||||
|
if ($ok) {
|
||||||
|
$gateway->status->set(edge_gateway_manager::STATUS_OFFLINE);
|
||||||
|
$metadata['uninstalled_at'] = $now;
|
||||||
|
$metadata['uninstall_error'] = null;
|
||||||
|
} else {
|
||||||
|
$metadata['uninstall_error'] = [
|
||||||
|
'code' => $errorCode,
|
||||||
|
'message' => $errorMessage,
|
||||||
|
'at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateway->metadata_json->set($metadata);
|
||||||
|
|
||||||
|
$this->manager()->logGatewayAudit(
|
||||||
|
$gatewayId,
|
||||||
|
(int)$gateway->department_id->value(),
|
||||||
|
$ok ? 'GATEWAY_OPERATION_COMPLETED' : 'GATEWAY_OPERATION_FAILED',
|
||||||
|
null,
|
||||||
|
[
|
||||||
|
'operation_id' => (int)$operation->id,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED,
|
||||||
|
'error_code' => $ok ? null : $errorCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function serializeOperation(edge_gateway_operations_o $operation, bool $includeEvents = true): array
|
||||||
|
{
|
||||||
|
$operationArray = $operation->asArray();
|
||||||
|
if ($includeEvents) {
|
||||||
|
$operationArray['events'] = $this->listOperationEvents(
|
||||||
|
(int)$operation->gateway_id->value(),
|
||||||
|
(int)$operation->id,
|
||||||
|
20
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operationArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function requireGateway(int $gatewayId): edge_gateways_o
|
||||||
|
{
|
||||||
|
$gateway = (new edge_gateways_o())->select($gatewayId);
|
||||||
|
if (!$gateway->exists() || $gateway->deleted_at->value() !== null) {
|
||||||
|
throw new Exception('Edge gateway not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function requireOperation(int $gatewayId, int $operationId): edge_gateway_operations_o
|
||||||
|
{
|
||||||
|
$operation = (new edge_gateway_operations_o())->select($operationId);
|
||||||
|
if (!$operation->exists() || $operation->deleted_at->value() !== null) {
|
||||||
|
throw new Exception('Edge gateway operation not found');
|
||||||
|
}
|
||||||
|
if ((int)$operation->gateway_id->value() !== $gatewayId) {
|
||||||
|
throw new Exception('Edge gateway operation does not belong to this gateway');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function appendEventRecord(
|
||||||
|
int $gatewayId,
|
||||||
|
int $operationId,
|
||||||
|
string $level,
|
||||||
|
?string $code,
|
||||||
|
string $message,
|
||||||
|
array $context
|
||||||
|
): array {
|
||||||
|
$eventId = (new edge_gateway_operation_events_o())->add_object([
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'level' => $level,
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
'context_json' => $context,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (new edge_gateway_operation_events_o())->select($eventId)->asArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshOperationLease(edge_gateway_operations_o $operation): void
|
||||||
|
{
|
||||||
|
$operation->last_progress_at->set($this->now());
|
||||||
|
$operation->lease_expires_at->set($this->leaseExpiry());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function classifyCompletionError(int $gatewayId, string $errorMessage): string
|
||||||
|
{
|
||||||
|
$gateway = $this->requireGateway($gatewayId);
|
||||||
|
$gatewayStatus = edge_gateway_manager::resolveGatewayStatus(
|
||||||
|
(string)$gateway->status->value(),
|
||||||
|
$gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($gatewayStatus === edge_gateway_manager::STATUS_OFFLINE) {
|
||||||
|
return self::ERROR_OFFLINE;
|
||||||
|
}
|
||||||
|
if ($gatewayStatus === edge_gateway_manager::STATUS_DEGRADED) {
|
||||||
|
return self::ERROR_STALE_HEARTBEAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedMessage = strtolower(trim($errorMessage));
|
||||||
|
if (str_contains($normalizedMessage, 'version')) {
|
||||||
|
return self::ERROR_UNSUPPORTED_VERSION;
|
||||||
|
}
|
||||||
|
if (str_contains($normalizedMessage, 'validation')) {
|
||||||
|
return self::ERROR_VALIDATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::ERROR_VALIDATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manager(): edge_gateway_manager
|
||||||
|
{
|
||||||
|
return $this->manager ?? new edge_gateway_manager();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function now(): string
|
||||||
|
{
|
||||||
|
return date('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function leaseExpiry(): string
|
||||||
|
{
|
||||||
|
return date('Y-m-d H:i:s', time() + self::OPERATION_LEASE_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeAgentInstanceId(?string $agentInstanceId, int $gatewayId): string
|
||||||
|
{
|
||||||
|
$candidate = trim((string)$agentInstanceId);
|
||||||
|
if ($candidate === '') {
|
||||||
|
return 'gateway-' . $gatewayId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($candidate, 0, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class edge_gateway_registry_service
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ?edge_gateway_manager $manager = null)
|
||||||
|
{
|
||||||
|
edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createInstallToken(int $departmentId, ?string $label, ?int $createdBy = null): array
|
||||||
|
{
|
||||||
|
return $this->manager()->createInstallToken($departmentId, $label, $createdBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function verifyInstallToken(string $plainToken): array
|
||||||
|
{
|
||||||
|
return $this->manager()->verifyInstallToken($plainToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function claimGateway(
|
||||||
|
string $token,
|
||||||
|
string $hostname,
|
||||||
|
?string $installedVersion = null,
|
||||||
|
array $metadata = []
|
||||||
|
): array {
|
||||||
|
$payload = $this->manager()->claimGateway($token, $hostname, $installedVersion, $metadata);
|
||||||
|
unset($payload['broker_url']);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array
|
||||||
|
{
|
||||||
|
return $this->manager()->recordHeartbeat($gatewayId, $plainToken, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array
|
||||||
|
{
|
||||||
|
return $this->manager()->updateGatewayMetadata($gatewayId, $payload, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array
|
||||||
|
{
|
||||||
|
return $this->manager()->setRelayBindings($gatewayId, $bindings, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function deleteGateway(int $gatewayId, ?int $userId = null): array
|
||||||
|
{
|
||||||
|
return $this->manager()->deleteGateway($gatewayId, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array
|
||||||
|
{
|
||||||
|
return $this->manager()->setDepartmentTransportMode($departmentId, $transportMode, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDepartmentTransportMode(int $departmentId): string
|
||||||
|
{
|
||||||
|
return $this->manager()->getDepartmentTransportMode($departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manager(): edge_gateway_manager
|
||||||
|
{
|
||||||
|
return $this->manager ?? new edge_gateway_manager();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
device_id VARCHAR(255) NOT NULL,
|
device_id VARCHAR(255) NOT NULL,
|
||||||
local_ip VARCHAR(64) NULL,
|
local_ip VARCHAR(64) NULL,
|
||||||
channel INT NOT NULL DEFAULT 0,
|
channel INT NOT NULL DEFAULT 0,
|
||||||
|
fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL',
|
||||||
binding_source VARCHAR(64) NOT NULL DEFAULT 'MANUAL',
|
binding_source VARCHAR(64) NOT NULL DEFAULT 'MANUAL',
|
||||||
approved_by INT NULL,
|
approved_by INT NULL,
|
||||||
approved_at DATETIME NULL,
|
approved_at DATETIME NULL,
|
||||||
@@ -123,81 +124,47 @@ class edge_gateway_schema_bootstrap
|
|||||||
INDEX idx_edge_gateway_command_type (command_type)
|
INDEX idx_edge_gateway_command_type (command_type)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs (
|
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
gateway_id INT NOT NULL,
|
gateway_id INT NOT NULL,
|
||||||
command_job_id INT NULL,
|
type VARCHAR(32) NOT NULL,
|
||||||
target_version VARCHAR(64) NOT NULL,
|
operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY',
|
||||||
release_channel VARCHAR(32) NOT NULL DEFAULT 'stable',
|
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||||
|
request_json JSON NULL,
|
||||||
|
summary_json JSON NULL,
|
||||||
|
result_json JSON NULL,
|
||||||
|
error_code VARCHAR(128) NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
correlation_id VARCHAR(128) NOT NULL,
|
||||||
|
agent_instance_id VARCHAR(128) NULL,
|
||||||
|
lease_expires_at DATETIME NULL,
|
||||||
|
last_progress_at DATETIME NULL,
|
||||||
|
attempt_count INT NOT NULL DEFAULT 0,
|
||||||
requested_by INT NULL,
|
requested_by INT NULL,
|
||||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
started_at DATETIME NULL,
|
started_at DATETIME NULL,
|
||||||
completed_at DATETIME NULL,
|
completed_at DATETIME NULL,
|
||||||
delivery_json JSON NULL,
|
|
||||||
result_json JSON NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
INDEX idx_edge_gateway_update_gateway (gateway_id),
|
UNIQUE KEY uniq_edge_gateway_operation_correlation (correlation_id),
|
||||||
INDEX idx_edge_gateway_update_command (command_job_id),
|
INDEX idx_edge_gateway_operations_gateway (gateway_id),
|
||||||
INDEX idx_edge_gateway_update_status (status)
|
INDEX idx_edge_gateway_operations_status (status),
|
||||||
|
INDEX idx_edge_gateway_operations_type (type)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions (
|
"CREATE TABLE IF NOT EXISTS edge_gateway_operation_events (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
operation_id INT NOT NULL,
|
||||||
gateway_id INT NOT NULL,
|
gateway_id INT NOT NULL,
|
||||||
reason TEXT NOT NULL,
|
level VARCHAR(16) NOT NULL DEFAULT 'INFO',
|
||||||
approval_status VARCHAR(32) NOT NULL DEFAULT 'APPROVED',
|
code VARCHAR(128) NULL,
|
||||||
session_token_hash VARCHAR(255) NOT NULL,
|
message TEXT NOT NULL,
|
||||||
requested_by INT NULL,
|
context_json JSON NULL,
|
||||||
approved_by INT NULL,
|
|
||||||
approved_at DATETIME NULL,
|
|
||||||
expires_at DATETIME NOT NULL,
|
|
||||||
opened_at DATETIME NULL,
|
|
||||||
closed_at DATETIME NULL,
|
|
||||||
transcript_text LONGTEXT NULL,
|
|
||||||
metadata_json JSON NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
INDEX idx_edge_gateway_operation_events_operation (operation_id),
|
||||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
INDEX idx_edge_gateway_operation_events_gateway (gateway_id),
|
||||||
INDEX idx_edge_gateway_shell_gateway (gateway_id),
|
INDEX idx_edge_gateway_operation_events_level (level)
|
||||||
INDEX idx_edge_gateway_shell_expires (expires_at),
|
|
||||||
INDEX idx_edge_gateway_shell_status (approval_status)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
gateway_id INT NOT NULL,
|
|
||||||
session_id INT NOT NULL,
|
|
||||||
action_type VARCHAR(32) NOT NULL,
|
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
|
||||||
payload_json JSON NULL,
|
|
||||||
delivery_json JSON NULL,
|
|
||||||
requested_by INT NULL,
|
|
||||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at DATETIME NULL,
|
|
||||||
error_message TEXT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
||||||
INDEX idx_edge_gateway_shell_action_gateway (gateway_id),
|
|
||||||
INDEX idx_edge_gateway_shell_action_session (session_id),
|
|
||||||
INDEX idx_edge_gateway_shell_action_status (status),
|
|
||||||
INDEX idx_edge_gateway_shell_action_type (action_type)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_events (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
gateway_id INT NOT NULL,
|
|
||||||
session_id INT NOT NULL,
|
|
||||||
event_type VARCHAR(32) NOT NULL,
|
|
||||||
payload_json JSON NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
||||||
INDEX idx_edge_gateway_shell_event_gateway (gateway_id),
|
|
||||||
INDEX idx_edge_gateway_shell_event_session (session_id),
|
|
||||||
INDEX idx_edge_gateway_shell_event_type (event_type)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs (
|
"CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs (
|
||||||
@@ -220,38 +187,58 @@ class edge_gateway_schema_bootstrap
|
|||||||
$db->query($sql);
|
$db->query($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!self::tableHasColumn('edge_gateway_update_jobs', 'command_job_id')) {
|
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
|
||||||
$db->query(
|
|
||||||
"ALTER TABLE edge_gateway_update_jobs
|
|
||||||
ADD COLUMN command_job_id INT NULL AFTER gateway_id,
|
|
||||||
ADD INDEX idx_edge_gateway_update_command (command_job_id)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self::tableHasColumn('edge_gateway_command_jobs', 'delivery_json')) {
|
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
|
||||||
$db->query(
|
|
||||||
"ALTER TABLE edge_gateway_command_jobs
|
|
||||||
ADD COLUMN delivery_json JSON NULL AFTER response_json"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self::tableHasColumn('edge_gateway_shell_action_jobs', 'delivery_json')) {
|
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
|
||||||
$db->query(
|
self::ensureColumn('edge_gateway_operations', 'operation_type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type");
|
||||||
"ALTER TABLE edge_gateway_shell_action_jobs
|
self::ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json');
|
||||||
ADD COLUMN delivery_json JSON NULL AFTER payload_json"
|
self::ensureColumn('edge_gateway_operations', 'result_json', 'JSON NULL AFTER summary_json');
|
||||||
);
|
self::ensureColumn('edge_gateway_operations', 'error_code', 'VARCHAR(128) NULL AFTER result_json');
|
||||||
}
|
self::ensureColumn('edge_gateway_operations', 'error_message', 'TEXT NULL AFTER error_code');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'correlation_id', 'VARCHAR(128) NULL AFTER error_message');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'requested_by', 'INT NULL AFTER attempt_count');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'requested_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER requested_by');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'started_at', 'DATETIME NULL AFTER requested_at');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'completed_at', 'DATETIME NULL AFTER started_at');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||||
|
self::ensureColumn('edge_gateway_operations', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at');
|
||||||
|
|
||||||
if (!self::tableHasColumn('edge_gateway_update_jobs', 'delivery_json')) {
|
self::ensureColumn('edge_gateway_operation_events', 'code', 'VARCHAR(128) NULL AFTER level');
|
||||||
$db->query(
|
self::ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message');
|
||||||
"ALTER TABLE edge_gateway_update_jobs
|
|
||||||
ADD COLUMN delivery_json JSON NULL AFTER completed_at"
|
self::ensureColumn('edge_gateway_audit_logs', 'actor_type', "VARCHAR(32) NOT NULL DEFAULT 'USER' AFTER actor_user_id");
|
||||||
);
|
self::ensureColumn('edge_gateway_audit_logs', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type");
|
||||||
}
|
self::ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity');
|
||||||
|
|
||||||
|
self::syncOperationTypeColumns();
|
||||||
|
|
||||||
self::$initialized = true;
|
self::$initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if (self::tableHasColumn($table, $column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
|
||||||
|
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"ALTER TABLE `$table`
|
||||||
|
ADD COLUMN `$column` $definition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static function tableHasColumn(string $table, string $column): bool
|
private static function tableHasColumn(string $table, string $column): bool
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
@@ -275,4 +262,30 @@ class edge_gateway_schema_bootstrap
|
|||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
return ((int)($row['c'] ?? 0)) > 0;
|
return ((int)($row['c'] ?? 0)) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function syncOperationTypeColumns(): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|
||||||
|
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"UPDATE edge_gateway_operations
|
||||||
|
SET type = operation_type
|
||||||
|
WHERE operation_type IS NOT NULL
|
||||||
|
AND operation_type <> ''
|
||||||
|
AND (type IS NULL OR type = '' OR type <> operation_type)"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"UPDATE edge_gateway_operations
|
||||||
|
SET operation_type = type
|
||||||
|
WHERE type IS NOT NULL
|
||||||
|
AND type <> ''
|
||||||
|
AND (operation_type IS NULL OR operation_type = '')"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class edge_gateway_view_service
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ?edge_gateway_manager $manager = null)
|
||||||
|
{
|
||||||
|
edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function listGateways(?int $departmentId = null, bool $includeDetail = true): array
|
||||||
|
{
|
||||||
|
return $this->manager()->listGateways($departmentId, $includeDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $gateways
|
||||||
|
* @return array<string,mixed>
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
|
||||||
|
{
|
||||||
|
return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function getGateway(int $gatewayId): array
|
||||||
|
{
|
||||||
|
return $this->manager()->getGateway($gatewayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manager(): edge_gateway_manager
|
||||||
|
{
|
||||||
|
return $this->manager ?? new edge_gateway_manager();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
|
|
||||||
#[AllowDynamicProperties] class email implements email_i
|
#[AllowDynamicProperties] class email implements email_i
|
||||||
{
|
{
|
||||||
|
public static array $fake_deliveries = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the email service
|
* Configuration for the email service
|
||||||
* @var email_c
|
* @var email_c
|
||||||
@@ -125,6 +127,17 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
*/
|
*/
|
||||||
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
||||||
{
|
{
|
||||||
|
if (self::isFakeDeliveryEnabled()) {
|
||||||
|
self::$fake_deliveries[] = [
|
||||||
|
'to' => $to,
|
||||||
|
'recipient_name' => $recipient_name,
|
||||||
|
'subject' => $subject,
|
||||||
|
'message' => $message,
|
||||||
|
'html' => $html,
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the email is blacklisted
|
// Check if the email is blacklisted
|
||||||
$blacklisted_emails = [
|
$blacklisted_emails = [
|
||||||
'invoice.dk@freja.com', // TODO: Make this dynamic.
|
'invoice.dk@freja.com', // TODO: Make this dynamic.
|
||||||
@@ -209,6 +222,16 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
$this->sendEmailMailerSend($to, $recipient_name, 'Betalingslink for bestilling #' . $order_id, '', $html);
|
$this->sendEmailMailerSend($to, $recipient_name, 'Betalingslink for bestilling #' . $order_id, '', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function resetFakeDeliveries(): void
|
||||||
|
{
|
||||||
|
self::$fake_deliveries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isFakeDeliveryEnabled(): bool
|
||||||
|
{
|
||||||
|
return getenv('EMAIL_FAKE_MODE') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a booking confirmation email
|
* Send a booking confirmation email
|
||||||
* @throws MailerSendException
|
* @throws MailerSendException
|
||||||
@@ -488,4 +511,4 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
$this->attachments
|
$this->attachments
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class order_bookings_counts_cache
|
||||||
|
{
|
||||||
|
public const PREFIX = 'order_bookings:counts:v1:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional runtime adapter for tests.
|
||||||
|
*/
|
||||||
|
private static ?object $adapter = null;
|
||||||
|
|
||||||
|
public static function setAdapterForTests(?object $adapter): void
|
||||||
|
{
|
||||||
|
self::$adapter = $adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTtl(): int
|
||||||
|
{
|
||||||
|
$raw = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
|
||||||
|
if ($raw === false || trim((string)$raw) === '') {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, (int)$raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function buildKey(array $context): string
|
||||||
|
{
|
||||||
|
$normalized = self::normalizeValue($context);
|
||||||
|
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($encoded)) {
|
||||||
|
$encoded = serialize($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::PREFIX . md5($encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getCounts(string $key): ?array
|
||||||
|
{
|
||||||
|
$raw = self::redisGet($key);
|
||||||
|
if ($raw === null || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::normalizeCounts($decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function storeCounts(string $key, array $counts, ?int $ttl = null): void
|
||||||
|
{
|
||||||
|
$cacheTtl = $ttl ?? self::getTtl();
|
||||||
|
if ($cacheTtl <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = self::normalizeCounts($counts);
|
||||||
|
if ($normalized === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($encoded)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::redisSetEx($key, $encoded, $cacheTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clearAll(): void
|
||||||
|
{
|
||||||
|
self::clearPattern(self::PREFIX . '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeCounts(array $counts): ?array
|
||||||
|
{
|
||||||
|
if (!array_key_exists('past', $counts) || !array_key_exists('current', $counts) || !array_key_exists('future', $counts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'past' => max(0, (int)$counts['past']),
|
||||||
|
'current' => max(0, (int)$counts['current']),
|
||||||
|
'future' => max(0, (int)$counts['future']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeValue(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return self::normalizeScalar($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_map([self::class, 'normalizeValue'], $value);
|
||||||
|
|
||||||
|
if (array_is_list($normalized)) {
|
||||||
|
if (self::isScalarList($normalized)) {
|
||||||
|
sort($normalized);
|
||||||
|
}
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($normalized);
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeScalar(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^-?\d+$/', $value) === 1) {
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (float)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isScalarList(array $value): bool
|
||||||
|
{
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if (is_array($item) || is_object($item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function clearPattern(string $pattern): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = self::redisClient();
|
||||||
|
if ($client === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$client->clear_keys($pattern);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Cache invalidation must never break request flow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function redisSetEx(string $key, string $value, int $ttl): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = self::redisClient();
|
||||||
|
if ($client === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$client->setEx($key, $value, $ttl);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Best-effort cache write.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function redisGet(string $key): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = self::redisClient();
|
||||||
|
if ($client === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = $client->get($key);
|
||||||
|
return is_string($value) ? $value : null;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function redisClient(): ?object
|
||||||
|
{
|
||||||
|
if (self::$adapter !== null) {
|
||||||
|
return self::$adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (defined('redis')) {
|
||||||
|
$instance = constant('redis');
|
||||||
|
if (is_object($instance)) {
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new redis())->connect();
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class order_bookings_list_cache
|
||||||
|
{
|
||||||
|
public const PREFIX = 'order_bookings:list:v1:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional runtime adapter for tests.
|
||||||
|
*/
|
||||||
|
private static ?object $adapter = null;
|
||||||
|
|
||||||
|
public static function setAdapterForTests(?object $adapter): void
|
||||||
|
{
|
||||||
|
self::$adapter = $adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTtl(): int
|
||||||
|
{
|
||||||
|
$raw = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
|
||||||
|
if ($raw === false || trim((string)$raw) === '') {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, (int)$raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function buildKey(array $context): string
|
||||||
|
{
|
||||||
|
$normalized = self::normalizeValue($context);
|
||||||
|
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($encoded)) {
|
||||||
|
$encoded = serialize($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::PREFIX . md5($encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPayload(string $key): ?array
|
||||||
|
{
|
||||||
|
$raw = self::redisGet($key);
|
||||||
|
if ($raw === null || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_key_exists('success', $decoded) || !array_key_exists('data', $decoded)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded['meta'] = isset($decoded['meta']) && is_array($decoded['meta']) ? $decoded['meta'] : [];
|
||||||
|
$decoded['includes'] = isset($decoded['includes']) && is_array($decoded['includes']) ? $decoded['includes'] : [];
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function storePayload(string $key, array $payload, ?int $ttl = null): void
|
||||||
|
{
|
||||||
|
$cacheTtl = $ttl ?? self::getTtl();
|
||||||
|
if ($cacheTtl <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (!is_string($encoded)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::redisSetEx($key, $encoded, $cacheTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clearAll(): void
|
||||||
|
{
|
||||||
|
self::clearPattern(self::PREFIX . '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeValue(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return self::normalizeScalar($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_map([self::class, 'normalizeValue'], $value);
|
||||||
|
|
||||||
|
if (array_is_list($normalized)) {
|
||||||
|
if (self::isScalarList($normalized)) {
|
||||||
|
sort($normalized);
|
||||||
|
}
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($normalized);
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeScalar(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^-?\d+$/', $value) === 1) {
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (float)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isScalarList(array $value): bool
|
||||||
|
{
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if (is_array($item) || is_object($item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function clearPattern(string $pattern): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = self::redisClient();
|
||||||
|
if ($client === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$client->clear_keys($pattern);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Cache invalidation must never break request flow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function redisSetEx(string $key, string $value, int $ttl): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = self::redisClient();
|
||||||
|
if ($client === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$client->setEx($key, $value, $ttl);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Best-effort cache write.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function redisGet(string $key): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = self::redisClient();
|
||||||
|
if ($client === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = $client->get($key);
|
||||||
|
return is_string($value) ? $value : null;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function redisClient(): ?object
|
||||||
|
{
|
||||||
|
if (self::$adapter !== null) {
|
||||||
|
return self::$adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (defined('redis')) {
|
||||||
|
$instance = constant('redis');
|
||||||
|
if (is_object($instance)) {
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new redis())->connect();
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,16 @@ class response implements response_i
|
|||||||
return $this->data;
|
return $this->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function get_meta(): array
|
||||||
|
{
|
||||||
|
return $this->meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_includes(): array
|
||||||
|
{
|
||||||
|
return $this->includes;
|
||||||
|
}
|
||||||
|
|
||||||
#[NoReturn] public function not_found(): void
|
#[NoReturn] public function not_found(): void
|
||||||
{
|
{
|
||||||
$this->error('Not found', 404);
|
$this->error('Not found', 404);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ require_once WD . '/modules/stripe/endpoints/stripe_endpoint_prices.php';
|
|||||||
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_invoice.php';
|
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_invoice.php';
|
||||||
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_readers.php';
|
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_readers.php';
|
||||||
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_payment_intents.php';
|
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_payment_intents.php';
|
||||||
|
require_once WD . '/classes/stripe_fake_http_client.php';
|
||||||
|
|
||||||
|
|
||||||
// Require all helper classes
|
// Require all helper classes
|
||||||
@@ -29,6 +30,7 @@ use stripe\endpoints\stripe_endpoint_prices;
|
|||||||
use stripe\endpoints\stripe_endpoint_product;
|
use stripe\endpoints\stripe_endpoint_product;
|
||||||
use stripe\endpoints\stripe_endpoint_readers;
|
use stripe\endpoints\stripe_endpoint_readers;
|
||||||
use stripe\stripe_c;
|
use stripe\stripe_c;
|
||||||
|
use Stripe\ApiRequestor;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +106,13 @@ class stripe implements stripe_i
|
|||||||
{
|
{
|
||||||
// Get the stripe client
|
// Get the stripe client
|
||||||
if (!isset($this->client)) {
|
if (!isset($this->client)) {
|
||||||
|
if (self::isFakeModeEnabled()) {
|
||||||
|
ApiRequestor::setHttpClient(new stripe_fake_http_client());
|
||||||
|
$this->client = new StripeClient([
|
||||||
|
'api_key' => 'sk_test_fake'
|
||||||
|
]);
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
// Require the module to be enabled
|
// Require the module to be enabled
|
||||||
self::requireModuleEnabled();
|
self::requireModuleEnabled();
|
||||||
// Require the secret key to be set
|
// Require the secret key to be set
|
||||||
@@ -118,6 +127,11 @@ class stripe implements stripe_i
|
|||||||
return $this->client;
|
return $this->client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function isFakeModeEnabled(): bool
|
||||||
|
{
|
||||||
|
return getenv('STRIPE_FAKE_MODE') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
@@ -153,4 +167,4 @@ class stripe implements stripe_i
|
|||||||
throw new Exception('Invalid publishable key');
|
throw new Exception('Invalid publishable key');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Stripe\HttpClient\ClientInterface;
|
||||||
|
|
||||||
|
class stripe_fake_http_client implements ClientInterface
|
||||||
|
{
|
||||||
|
private const DEFAULT_STATE = [
|
||||||
|
'next_customer' => 1,
|
||||||
|
'next_price' => 1,
|
||||||
|
'next_invoice' => 1,
|
||||||
|
'next_invoice_item' => 1,
|
||||||
|
'customers' => [],
|
||||||
|
'prices' => [],
|
||||||
|
'products' => [],
|
||||||
|
'invoice_items' => [],
|
||||||
|
'invoices' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function resetStore(): void
|
||||||
|
{
|
||||||
|
self::writeStore(self::DEFAULT_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setInvoiceState(string $invoiceId, array $attributes): void
|
||||||
|
{
|
||||||
|
$store = self::readStore();
|
||||||
|
$invoice = $store['invoices'][$invoiceId] ?? null;
|
||||||
|
if (!$invoice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store['invoices'][$invoiceId] = [
|
||||||
|
...$invoice,
|
||||||
|
...$attributes,
|
||||||
|
];
|
||||||
|
self::writeStore($store);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1')
|
||||||
|
{
|
||||||
|
$path = (string)parse_url((string)$absUrl, PHP_URL_PATH);
|
||||||
|
$store = self::readStore();
|
||||||
|
|
||||||
|
if ($method === 'post' && $path === '/v1/customers') {
|
||||||
|
$customerId = sprintf('cus_fake_%06d', (int)$store['next_customer']);
|
||||||
|
$store['next_customer']++;
|
||||||
|
$customer = [
|
||||||
|
'id' => $customerId,
|
||||||
|
'object' => 'customer',
|
||||||
|
'email' => (string)($params['email'] ?? ''),
|
||||||
|
];
|
||||||
|
$store['customers'][$customerId] = $customer;
|
||||||
|
self::writeStore($store);
|
||||||
|
|
||||||
|
return $this->jsonResponse($customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'get' && preg_match('#^/v1/customers/(?P<id>[^/]+)$#', $path, $matches)) {
|
||||||
|
$customer = $store['customers'][$matches['id']] ?? null;
|
||||||
|
if (!$customer) {
|
||||||
|
return $this->errorResponse(404, 'resource_missing', 'No such customer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonResponse($customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'get' && preg_match('#^/v1/products/(?P<id>[^/]+)$#', $path, $matches)) {
|
||||||
|
$product = $store['products'][$matches['id']] ?? null;
|
||||||
|
if (!$product) {
|
||||||
|
return $this->errorResponse(404, 'resource_missing', 'No such product.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonResponse($product);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'post' && $path === '/v1/products') {
|
||||||
|
$productId = (string)($params['id'] ?? sprintf('prod_fake_%06d', count($store['products']) + 1));
|
||||||
|
$product = [
|
||||||
|
'id' => $productId,
|
||||||
|
'object' => 'product',
|
||||||
|
'name' => (string)($params['name'] ?? 'Fake Product'),
|
||||||
|
];
|
||||||
|
$store['products'][$productId] = $product;
|
||||||
|
self::writeStore($store);
|
||||||
|
|
||||||
|
return $this->jsonResponse($product);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'post' && $path === '/v1/prices') {
|
||||||
|
$priceId = sprintf('price_fake_%06d', (int)$store['next_price']);
|
||||||
|
$store['next_price']++;
|
||||||
|
$price = [
|
||||||
|
'id' => $priceId,
|
||||||
|
'object' => 'price',
|
||||||
|
'product' => (string)($params['product'] ?? ''),
|
||||||
|
'unit_amount' => (int)($params['unit_amount'] ?? 0),
|
||||||
|
'currency' => strtolower((string)($params['currency'] ?? 'dkk')),
|
||||||
|
];
|
||||||
|
$store['prices'][$priceId] = $price;
|
||||||
|
self::writeStore($store);
|
||||||
|
|
||||||
|
return $this->jsonResponse($price);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'post' && $path === '/v1/invoices') {
|
||||||
|
$invoiceId = sprintf('in_fake_%06d', (int)$store['next_invoice']);
|
||||||
|
$store['next_invoice']++;
|
||||||
|
$invoice = [
|
||||||
|
'id' => $invoiceId,
|
||||||
|
'object' => 'invoice',
|
||||||
|
'customer' => (string)($params['customer'] ?? ''),
|
||||||
|
'status' => 'draft',
|
||||||
|
'paid' => false,
|
||||||
|
'amount_due' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'collection_method' => (string)($params['collection_method'] ?? 'send_invoice'),
|
||||||
|
'hosted_invoice_url' => sprintf('https://stripe.test/invoices/%s', $invoiceId),
|
||||||
|
'metadata' => is_array($params['metadata'] ?? null) ? $params['metadata'] : [],
|
||||||
|
'lines' => [],
|
||||||
|
];
|
||||||
|
$store['invoices'][$invoiceId] = $invoice;
|
||||||
|
self::writeStore($store);
|
||||||
|
|
||||||
|
return $this->jsonResponse($invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'post' && $path === '/v1/invoiceitems') {
|
||||||
|
$invoiceId = (string)($params['invoice'] ?? '');
|
||||||
|
$invoice = $store['invoices'][$invoiceId] ?? null;
|
||||||
|
if (!$invoice) {
|
||||||
|
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceItemId = sprintf('ii_fake_%06d', (int)$store['next_invoice_item']);
|
||||||
|
$store['next_invoice_item']++;
|
||||||
|
$priceId = (string)($params['price'] ?? '');
|
||||||
|
$price = $store['prices'][$priceId] ?? ['unit_amount' => 0];
|
||||||
|
$invoiceItem = [
|
||||||
|
'id' => $invoiceItemId,
|
||||||
|
'object' => 'invoiceitem',
|
||||||
|
'invoice' => $invoiceId,
|
||||||
|
'customer' => (string)($params['customer'] ?? ''),
|
||||||
|
'price' => $priceId,
|
||||||
|
'amount' => (int)($price['unit_amount'] ?? 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
$store['invoice_items'][$invoiceItemId] = $invoiceItem;
|
||||||
|
$store['invoices'][$invoiceId]['lines'][] = $invoiceItemId;
|
||||||
|
$store['invoices'][$invoiceId]['amount_due'] += (int)($price['unit_amount'] ?? 0);
|
||||||
|
self::writeStore($store);
|
||||||
|
|
||||||
|
return $this->jsonResponse($invoiceItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/finalize$#', $path, $matches)) {
|
||||||
|
$invoiceId = $matches['id'];
|
||||||
|
$invoice = $store['invoices'][$invoiceId] ?? null;
|
||||||
|
if (!$invoice) {
|
||||||
|
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice['status'] = 'open';
|
||||||
|
$store['invoices'][$invoiceId] = $invoice;
|
||||||
|
self::writeStore($store);
|
||||||
|
|
||||||
|
return $this->jsonResponse($invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/void$#', $path, $matches)) {
|
||||||
|
$invoiceId = $matches['id'];
|
||||||
|
$invoice = $store['invoices'][$invoiceId] ?? null;
|
||||||
|
if (!$invoice) {
|
||||||
|
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice['status'] = 'void';
|
||||||
|
$invoice['paid'] = false;
|
||||||
|
$store['invoices'][$invoiceId] = $invoice;
|
||||||
|
self::writeStore($store);
|
||||||
|
|
||||||
|
return $this->jsonResponse($invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'get' && preg_match('#^/v1/invoices/(?P<id>[^/]+)$#', $path, $matches)) {
|
||||||
|
$invoice = $store['invoices'][$matches['id']] ?? null;
|
||||||
|
if (!$invoice) {
|
||||||
|
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonResponse($invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->errorResponse(404, 'resource_missing', 'Unsupported fake Stripe request: ' . $method . ' ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jsonResponse(array $payload, int $status = 200): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
$status,
|
||||||
|
[
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Request-Id' => 'req_fake_stripe',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function errorResponse(int $status, string $code, string $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
json_encode([
|
||||||
|
'error' => [
|
||||||
|
'type' => 'invalid_request_error',
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
$status,
|
||||||
|
[
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Request-Id' => 'req_fake_stripe_error',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function readStore(): array
|
||||||
|
{
|
||||||
|
$path = self::storePath();
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return self::DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = file_get_contents($path);
|
||||||
|
if ($json === false || trim($json) === '') {
|
||||||
|
return self::DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return self::DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...self::DEFAULT_STATE,
|
||||||
|
...$decoded,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function writeStore(array $store): void
|
||||||
|
{
|
||||||
|
file_put_contents(
|
||||||
|
self::storePath(),
|
||||||
|
json_encode($store, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function storePath(): string
|
||||||
|
{
|
||||||
|
$path = trim((string)getenv('STRIPE_FAKE_STORE_PATH'));
|
||||||
|
if ($path !== '') {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-stripe-fake-store.json';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
final class workfeed_shift_time_resolver
|
||||||
|
{
|
||||||
|
private const MAX_UNAPPROVED_EXTENSION_SECONDS = 6 * 3600;
|
||||||
|
private const MAX_UNAPPROVED_SHIFT_SPAN_SECONDS = 24 * 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{actualStart:DateTime,scheduledEnd:DateTime,actualEnd:DateTime,hasApproval:bool}|null
|
||||||
|
*/
|
||||||
|
public static function resolveShiftTiming(mixed $record_value): ?array
|
||||||
|
{
|
||||||
|
$record = self::normalizeRecord($record_value);
|
||||||
|
if ($record === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual_start = self::firstDateTimeFromPaths($record, [
|
||||||
|
'actualStart',
|
||||||
|
'actualStartTime',
|
||||||
|
'clockIn',
|
||||||
|
'clockInTime',
|
||||||
|
'start',
|
||||||
|
'startTime',
|
||||||
|
'from',
|
||||||
|
'approval.originalStart',
|
||||||
|
]);
|
||||||
|
$scheduled_end = self::firstDateTimeFromPaths($record, [
|
||||||
|
'approval.originalEnd',
|
||||||
|
'end',
|
||||||
|
'endTime',
|
||||||
|
'to',
|
||||||
|
]);
|
||||||
|
$actual_only_end = self::firstDateTimeFromPaths($record, [
|
||||||
|
'actualEnd',
|
||||||
|
'actualEndTime',
|
||||||
|
'clockOut',
|
||||||
|
'clockOutTime',
|
||||||
|
]);
|
||||||
|
$current_end = self::firstDateTimeFromPaths($record, [
|
||||||
|
'end',
|
||||||
|
'endTime',
|
||||||
|
'to',
|
||||||
|
]);
|
||||||
|
$saved_actual_end = $actual_only_end ?? $current_end;
|
||||||
|
|
||||||
|
if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual_end = self::resolveActualEnd($record, $actual_start, $scheduled_end, $saved_actual_end, $actual_only_end);
|
||||||
|
if ($actual_end->getTimestamp() <= $actual_start->getTimestamp()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'actualStart' => $actual_start,
|
||||||
|
'scheduledEnd' => $scheduled_end,
|
||||||
|
'actualEnd' => $actual_end,
|
||||||
|
'hasApproval' => self::hasShiftApproval($record),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function calculateOvertimeHoursInRange(mixed $record_value, DateTime $range_start, DateTime $range_end_exclusive): float
|
||||||
|
{
|
||||||
|
$timing = self::resolveShiftTiming($record_value);
|
||||||
|
if ($timing === null) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduled_end_ts = $timing['scheduledEnd']->getTimestamp();
|
||||||
|
$actual_end_ts = $timing['actualEnd']->getTimestamp();
|
||||||
|
if ($actual_end_ts <= $scheduled_end_ts) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
|
||||||
|
$overtime_end_ts = min($actual_end_ts, $range_end_exclusive->getTimestamp());
|
||||||
|
if ($overtime_end_ts <= $overtime_start_ts) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function normalizeRecord(mixed $record): array
|
||||||
|
{
|
||||||
|
if (is_array($record)) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
if (is_object($record)) {
|
||||||
|
return get_object_vars($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getNestedRecordValue(array $record, string $path): mixed
|
||||||
|
{
|
||||||
|
$segments = explode('.', $path);
|
||||||
|
$current = $record;
|
||||||
|
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
if (is_array($current)) {
|
||||||
|
if (!array_key_exists($segment, $current)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$current = $current[$segment];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($current)) {
|
||||||
|
if (!property_exists($current, $segment)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$current = $current->$segment;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $paths
|
||||||
|
*/
|
||||||
|
private static function firstDateTimeFromPaths(array $record, array $paths): ?DateTime
|
||||||
|
{
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$parsed = self::parseDateTimeValue(self::getNestedRecordValue($record, $path));
|
||||||
|
if ($parsed !== null) {
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseDateTimeValue(mixed $value): ?DateTime
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
$normalized = trim($value);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new DateTime($normalized);
|
||||||
|
} catch (Exception) {
|
||||||
|
if (!is_numeric($normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = (float)$normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value) || is_float($value)) {
|
||||||
|
if (!is_finite((float)$value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = (float)$value;
|
||||||
|
if ($timestamp > 9999999999) {
|
||||||
|
$timestamp /= 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$date = new DateTime('@' . (string)(int)round($timestamp));
|
||||||
|
$date->setTimezone(new DateTimeZone('UTC'));
|
||||||
|
return $date;
|
||||||
|
} catch (Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = self::normalizeRecord($value);
|
||||||
|
foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
|
||||||
|
if (!array_key_exists($key, $record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = self::parseDateTimeValue($record[$key]);
|
||||||
|
if ($parsed !== null) {
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasShiftApproval(array $record): bool
|
||||||
|
{
|
||||||
|
if (!array_key_exists('approval', $record)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$approval = $record['approval'];
|
||||||
|
if ($approval === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (is_array($approval)) {
|
||||||
|
return $approval !== [];
|
||||||
|
}
|
||||||
|
if (is_object($approval)) {
|
||||||
|
return get_object_vars($approval) !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveActualEnd(
|
||||||
|
array $record,
|
||||||
|
DateTime $actual_start,
|
||||||
|
DateTime $scheduled_end,
|
||||||
|
DateTime $saved_actual_end,
|
||||||
|
?DateTime $actual_only_end
|
||||||
|
): DateTime {
|
||||||
|
if (self::hasShiftApproval($record) || $actual_only_end !== null) {
|
||||||
|
return $saved_actual_end;
|
||||||
|
}
|
||||||
|
|
||||||
|
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
|
||||||
|
if ($update_time === null) {
|
||||||
|
return $saved_actual_end;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shift_start_ts = $actual_start->getTimestamp();
|
||||||
|
$scheduled_end_ts = $scheduled_end->getTimestamp();
|
||||||
|
$saved_actual_end_ts = $saved_actual_end->getTimestamp();
|
||||||
|
$update_ts = $update_time->getTimestamp();
|
||||||
|
$fallback_base_ts = max($saved_actual_end_ts, $scheduled_end_ts);
|
||||||
|
|
||||||
|
if ($update_ts <= $fallback_base_ts) {
|
||||||
|
return $saved_actual_end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use updateTime as an overtime hint when no explicit actual end was saved.
|
||||||
|
if (($update_ts - $scheduled_end_ts) > self::MAX_UNAPPROVED_EXTENSION_SECONDS) {
|
||||||
|
return $saved_actual_end;
|
||||||
|
}
|
||||||
|
if (($update_ts - $shift_start_ts) > self::MAX_UNAPPROVED_SHIFT_SPAN_SECONDS) {
|
||||||
|
return $saved_actual_end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $update_time;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace config;
|
||||||
|
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class economic_transaction_draft_customer_number_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'economic',
|
||||||
|
'transactionDraftCustomerNumber',
|
||||||
|
'int',
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
'Customer number used for transaction drafts that must not export to e-conomic',
|
||||||
|
'99999999',
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ require_once WD . '/modules/economic/config/economic_payment_terms_c.php';
|
|||||||
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
||||||
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
||||||
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
|
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
|
||||||
|
require_once WD . '/modules/economic/config/economic_transaction_draft_customer_number_c.php';
|
||||||
|
|
||||||
use config\economic_invoice_layout_c;
|
use config\economic_invoice_layout_c;
|
||||||
use config\economic_payment_terms_c;
|
use config\economic_payment_terms_c;
|
||||||
use config\economic_admin_fee_monthly_c;
|
use config\economic_admin_fee_monthly_c;
|
||||||
use config\economic_admin_fee_order_c;
|
use config\economic_admin_fee_order_c;
|
||||||
use config\economic_fee_product_id_c;
|
use config\economic_fee_product_id_c;
|
||||||
|
use config\economic_transaction_draft_customer_number_c;
|
||||||
use traits\module_config_t;
|
use traits\module_config_t;
|
||||||
|
|
||||||
class economic_c
|
class economic_c
|
||||||
@@ -21,6 +23,7 @@ class economic_c
|
|||||||
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
||||||
public economic_admin_fee_order_c $admin_fee_order;
|
public economic_admin_fee_order_c $admin_fee_order;
|
||||||
public economic_fee_product_id_c $fee_product_id;
|
public economic_fee_product_id_c $fee_product_id;
|
||||||
|
public economic_transaction_draft_customer_number_c $transaction_draft_customer_number;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -30,12 +33,14 @@ class economic_c
|
|||||||
economic_payment_terms_c::class,
|
economic_payment_terms_c::class,
|
||||||
economic_admin_fee_monthly_c::class,
|
economic_admin_fee_monthly_c::class,
|
||||||
economic_admin_fee_order_c::class,
|
economic_admin_fee_order_c::class,
|
||||||
economic_fee_product_id_c::class
|
economic_fee_product_id_c::class,
|
||||||
|
economic_transaction_draft_customer_number_c::class,
|
||||||
]);
|
]);
|
||||||
$this->invoice_layout = new economic_invoice_layout_c();
|
$this->invoice_layout = new economic_invoice_layout_c();
|
||||||
$this->payment_terms = new economic_payment_terms_c();
|
$this->payment_terms = new economic_payment_terms_c();
|
||||||
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
|
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
|
||||||
$this->admin_fee_order = new economic_admin_fee_order_c();
|
$this->admin_fee_order = new economic_admin_fee_order_c();
|
||||||
$this->fee_product_id = new economic_fee_product_id_c();
|
$this->fee_product_id = new economic_fee_product_id_c();
|
||||||
|
$this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ class stripe_endpoint_invoice
|
|||||||
return self::getClient()->invoices->retrieve($id);
|
return self::getClient()->invoices->retrieve($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiErrorException
|
||||||
|
*/
|
||||||
|
public function void(string $id): Invoice
|
||||||
|
{
|
||||||
|
return self::getClient()->invoices->voidInvoice($id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ApiErrorException
|
* @throws ApiErrorException
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
@@ -53,13 +61,47 @@ class stripe_endpoint_invoice
|
|||||||
return $this->create(
|
return $this->create(
|
||||||
$line_items,
|
$line_items,
|
||||||
$stripe_customer_id,
|
$stripe_customer_id,
|
||||||
[
|
$this->buildOrderMetadata($order, $stripe_customer_id)
|
||||||
'order_id' => $order_id,
|
|
||||||
'customer_id' => $stripe_customer_id
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildOrderMetadata(orders_o $order, string $stripe_customer_id): array
|
||||||
|
{
|
||||||
|
$metadata = [
|
||||||
|
'order_id' => (string)$order->id,
|
||||||
|
'customer_id' => (string)$order->customer_id->value(),
|
||||||
|
'stripe_customer_id' => $stripe_customer_id,
|
||||||
|
'department_id' => (string)$order->department_id->value(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$reference = trim((string)$order->reference->value());
|
||||||
|
if ($reference !== '') {
|
||||||
|
$metadata['reference'] = $reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
$po = trim((string)$order->po->value());
|
||||||
|
if ($po !== '') {
|
||||||
|
$metadata['po'] = $po;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reg1 = trim((string)$order->reg_1->value());
|
||||||
|
if ($reg1 !== '') {
|
||||||
|
$metadata['reg_1'] = $reg1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reg2 = trim((string)$order->reg_2->value());
|
||||||
|
if ($reg2 !== '') {
|
||||||
|
$metadata['reg_2'] = $reg2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reg3 = trim((string)$order->reg_3->value());
|
||||||
|
if ($reg3 !== '') {
|
||||||
|
$metadata['reg_3'] = $reg3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $metadata;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new payment link
|
* Create a new payment link
|
||||||
* @param stripe_line_items $line_items
|
* @param stripe_line_items $line_items
|
||||||
@@ -96,4 +138,4 @@ class stripe_endpoint_invoice
|
|||||||
return $invoice->finalizeInvoice();
|
return $invoice->finalizeInvoice();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ class collected_order_invoices_o extends db
|
|||||||
if (empty($this->customer_number->value())) {
|
if (empty($this->customer_number->value())) {
|
||||||
throw new Exception('Customer number is not set');
|
throw new Exception('Customer number is not set');
|
||||||
}
|
}
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||||
if (!$ignore_closed) {
|
if (!$ignore_closed) {
|
||||||
// Require the invoice collection to be open
|
// Require the invoice collection to be open
|
||||||
self::requireOpen();
|
self::requireOpen();
|
||||||
@@ -479,6 +480,7 @@ class collected_order_invoices_o extends db
|
|||||||
{
|
{
|
||||||
// Require the invoice collection to be selected
|
// Require the invoice collection to be selected
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||||
// Check if the invoice draft already exists
|
// Check if the invoice draft already exists
|
||||||
if (self::isDraftExisting() || self::isBooked()) {
|
if (self::isDraftExisting() || self::isBooked()) {
|
||||||
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
|
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
|
||||||
@@ -1487,4 +1489,4 @@ class collected_order_invoices_o extends db
|
|||||||
{
|
{
|
||||||
$this->objectChanged();
|
$this->objectChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace objects;
|
||||||
|
|
||||||
|
use classes\db;
|
||||||
|
use classes\edge_gateway_schema_bootstrap;
|
||||||
|
use classes\object_property;
|
||||||
|
use traits\db_object_t;
|
||||||
|
|
||||||
|
class edge_gateway_operation_events_o extends db
|
||||||
|
{
|
||||||
|
use db_object_t;
|
||||||
|
|
||||||
|
public object_property $operation_id;
|
||||||
|
public object_property $gateway_id;
|
||||||
|
public object_property $level;
|
||||||
|
public object_property $code;
|
||||||
|
public object_property $message;
|
||||||
|
public object_property $context_json;
|
||||||
|
public object_property $created_at;
|
||||||
|
|
||||||
|
public function structure(): void
|
||||||
|
{
|
||||||
|
edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
$this->setTable('edge_gateway_operation_events');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getObjectProperties(): void
|
||||||
|
{
|
||||||
|
$this->operation_id = new object_property($this->table, $this->id, 'operation_id', 'int', false);
|
||||||
|
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||||
|
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
|
||||||
|
$this->code = new object_property($this->table, $this->id, 'code', 'string', false);
|
||||||
|
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
|
||||||
|
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
|
||||||
|
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function objectChanged(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function asArray(): array
|
||||||
|
{
|
||||||
|
$this->requireSelected();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)$this->id,
|
||||||
|
'operation_id' => (int)$this->operation_id->value(),
|
||||||
|
'gateway_id' => (int)$this->gateway_id->value(),
|
||||||
|
'level' => (string)$this->level->value(),
|
||||||
|
'code' => $this->code->value() === null ? null : (string)$this->code->value(),
|
||||||
|
'message' => (string)$this->message->value(),
|
||||||
|
'context' => (array)($this->context_json->value() ?? []),
|
||||||
|
'created_at' => (string)$this->created_at->value(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-17
@@ -7,21 +7,28 @@ use classes\edge_gateway_schema_bootstrap;
|
|||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
|
|
||||||
class edge_gateway_update_jobs_o extends db
|
class edge_gateway_operations_o extends db
|
||||||
{
|
{
|
||||||
use db_object_t;
|
use db_object_t;
|
||||||
|
|
||||||
public object_property $gateway_id;
|
public object_property $gateway_id;
|
||||||
public object_property $command_job_id;
|
public object_property $type;
|
||||||
public object_property $target_version;
|
public object_property $operation_type;
|
||||||
public object_property $release_channel;
|
|
||||||
public object_property $status;
|
public object_property $status;
|
||||||
|
public object_property $request_json;
|
||||||
|
public object_property $summary_json;
|
||||||
|
public object_property $result_json;
|
||||||
|
public object_property $error_code;
|
||||||
|
public object_property $error_message;
|
||||||
|
public object_property $correlation_id;
|
||||||
|
public object_property $agent_instance_id;
|
||||||
|
public object_property $lease_expires_at;
|
||||||
|
public object_property $last_progress_at;
|
||||||
|
public object_property $attempt_count;
|
||||||
public object_property $requested_by;
|
public object_property $requested_by;
|
||||||
public object_property $requested_at;
|
public object_property $requested_at;
|
||||||
public object_property $started_at;
|
public object_property $started_at;
|
||||||
public object_property $completed_at;
|
public object_property $completed_at;
|
||||||
public object_property $delivery_json;
|
|
||||||
public object_property $result_json;
|
|
||||||
public object_property $created_at;
|
public object_property $created_at;
|
||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $deleted_at;
|
public object_property $deleted_at;
|
||||||
@@ -29,22 +36,29 @@ class edge_gateway_update_jobs_o extends db
|
|||||||
public function structure(): void
|
public function structure(): void
|
||||||
{
|
{
|
||||||
edge_gateway_schema_bootstrap::ensureTables();
|
edge_gateway_schema_bootstrap::ensureTables();
|
||||||
$this->setTable('edge_gateway_update_jobs');
|
$this->setTable('edge_gateway_operations');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getObjectProperties(): void
|
public function getObjectProperties(): void
|
||||||
{
|
{
|
||||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||||
$this->command_job_id = new object_property($this->table, $this->id, 'command_job_id', 'int', false);
|
$this->type = new object_property($this->table, $this->id, 'type', 'string', false);
|
||||||
$this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false);
|
$this->operation_type = new object_property($this->table, $this->id, 'operation_type', 'string', false);
|
||||||
$this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false);
|
|
||||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||||
|
$this->request_json = new object_property($this->table, $this->id, 'request_json', 'json', false);
|
||||||
|
$this->summary_json = new object_property($this->table, $this->id, 'summary_json', 'json', false);
|
||||||
|
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false);
|
||||||
|
$this->error_code = new object_property($this->table, $this->id, 'error_code', 'string', false);
|
||||||
|
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false);
|
||||||
|
$this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', false);
|
||||||
|
$this->agent_instance_id = new object_property($this->table, $this->id, 'agent_instance_id', 'string', false);
|
||||||
|
$this->lease_expires_at = new object_property($this->table, $this->id, 'lease_expires_at', 'string', false);
|
||||||
|
$this->last_progress_at = new object_property($this->table, $this->id, 'last_progress_at', 'string', false);
|
||||||
|
$this->attempt_count = new object_property($this->table, $this->id, 'attempt_count', 'int', false);
|
||||||
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
||||||
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
||||||
$this->started_at = new object_property($this->table, $this->id, 'started_at', 'string', false);
|
$this->started_at = new object_property($this->table, $this->id, 'started_at', 'string', false);
|
||||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
||||||
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
|
|
||||||
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false);
|
|
||||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||||
@@ -61,16 +75,22 @@ class edge_gateway_update_jobs_o extends db
|
|||||||
return [
|
return [
|
||||||
'id' => (int)$this->id,
|
'id' => (int)$this->id,
|
||||||
'gateway_id' => (int)$this->gateway_id->value(),
|
'gateway_id' => (int)$this->gateway_id->value(),
|
||||||
'command_job_id' => $this->command_job_id->value() === null ? null : (int)$this->command_job_id->value(),
|
'type' => (string)($this->type->value() ?? $this->operation_type->value() ?? ''),
|
||||||
'target_version' => (string)$this->target_version->value(),
|
|
||||||
'release_channel' => (string)$this->release_channel->value(),
|
|
||||||
'status' => (string)$this->status->value(),
|
'status' => (string)$this->status->value(),
|
||||||
|
'request' => (array)($this->request_json->value() ?? []),
|
||||||
|
'summary' => (array)($this->summary_json->value() ?? []),
|
||||||
|
'result' => (array)($this->result_json->value() ?? []),
|
||||||
|
'error_code' => $this->error_code->value() === null ? null : (string)$this->error_code->value(),
|
||||||
|
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
|
||||||
|
'correlation_id' => (string)$this->correlation_id->value(),
|
||||||
|
'agent_instance_id' => $this->agent_instance_id->value() === null ? null : (string)$this->agent_instance_id->value(),
|
||||||
|
'lease_expires_at' => $this->lease_expires_at->value() === null ? null : (string)$this->lease_expires_at->value(),
|
||||||
|
'last_progress_at' => $this->last_progress_at->value() === null ? null : (string)$this->last_progress_at->value(),
|
||||||
|
'attempt_count' => (int)($this->attempt_count->value() ?? 0),
|
||||||
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
||||||
'requested_at' => (string)$this->requested_at->value(),
|
'requested_at' => (string)$this->requested_at->value(),
|
||||||
'started_at' => $this->started_at->value() === null ? null : (string)$this->started_at->value(),
|
'started_at' => $this->started_at->value() === null ? null : (string)$this->started_at->value(),
|
||||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||||
'delivery' => (array)($this->delivery_json->value() ?? []),
|
|
||||||
'result' => (array)($this->result_json->value() ?? []),
|
|
||||||
'created_at' => (string)$this->created_at->value(),
|
'created_at' => (string)$this->created_at->value(),
|
||||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||||
];
|
];
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace objects;
|
|
||||||
|
|
||||||
use classes\db;
|
|
||||||
use classes\edge_gateway_schema_bootstrap;
|
|
||||||
use classes\object_property;
|
|
||||||
use traits\db_object_t;
|
|
||||||
|
|
||||||
class edge_gateway_shell_action_jobs_o extends db
|
|
||||||
{
|
|
||||||
use db_object_t;
|
|
||||||
|
|
||||||
public object_property $gateway_id;
|
|
||||||
public object_property $session_id;
|
|
||||||
public object_property $action_type;
|
|
||||||
public object_property $status;
|
|
||||||
public object_property $payload_json;
|
|
||||||
public object_property $delivery_json;
|
|
||||||
public object_property $requested_by;
|
|
||||||
public object_property $requested_at;
|
|
||||||
public object_property $completed_at;
|
|
||||||
public object_property $error_message;
|
|
||||||
public object_property $created_at;
|
|
||||||
public object_property $updated_at;
|
|
||||||
public object_property $deleted_at;
|
|
||||||
|
|
||||||
public function structure(): void
|
|
||||||
{
|
|
||||||
edge_gateway_schema_bootstrap::ensureTables();
|
|
||||||
$this->setTable('edge_gateway_shell_action_jobs');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getObjectProperties(): void
|
|
||||||
{
|
|
||||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
|
||||||
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
|
|
||||||
$this->action_type = new object_property($this->table, $this->id, 'action_type', 'string', false);
|
|
||||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
|
||||||
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
|
|
||||||
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
|
|
||||||
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
|
||||||
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
|
||||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
|
||||||
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false);
|
|
||||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
|
||||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
|
||||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function objectChanged(): void
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function asArray(): array
|
|
||||||
{
|
|
||||||
$this->requireSelected();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => (int)$this->id,
|
|
||||||
'gateway_id' => (int)$this->gateway_id->value(),
|
|
||||||
'session_id' => (int)$this->session_id->value(),
|
|
||||||
'action_type' => (string)$this->action_type->value(),
|
|
||||||
'status' => (string)$this->status->value(),
|
|
||||||
'payload' => (array)($this->payload_json->value() ?? []),
|
|
||||||
'delivery' => (array)($this->delivery_json->value() ?? []),
|
|
||||||
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
|
||||||
'requested_at' => (string)$this->requested_at->value(),
|
|
||||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
|
||||||
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
|
|
||||||
'created_at' => (string)$this->created_at->value(),
|
|
||||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace objects;
|
|
||||||
|
|
||||||
use classes\db;
|
|
||||||
use classes\edge_gateway_schema_bootstrap;
|
|
||||||
use classes\object_property;
|
|
||||||
use traits\db_object_t;
|
|
||||||
|
|
||||||
class edge_gateway_shell_events_o extends db
|
|
||||||
{
|
|
||||||
use db_object_t;
|
|
||||||
|
|
||||||
public object_property $gateway_id;
|
|
||||||
public object_property $session_id;
|
|
||||||
public object_property $event_type;
|
|
||||||
public object_property $payload_json;
|
|
||||||
public object_property $created_at;
|
|
||||||
public object_property $deleted_at;
|
|
||||||
|
|
||||||
public function structure(): void
|
|
||||||
{
|
|
||||||
edge_gateway_schema_bootstrap::ensureTables();
|
|
||||||
$this->setTable('edge_gateway_shell_events');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getObjectProperties(): void
|
|
||||||
{
|
|
||||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
|
||||||
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
|
|
||||||
$this->event_type = new object_property($this->table, $this->id, 'event_type', 'string', false);
|
|
||||||
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
|
|
||||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
|
||||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function objectChanged(): void
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function asArray(): array
|
|
||||||
{
|
|
||||||
$this->requireSelected();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => (int)$this->id,
|
|
||||||
'gateway_id' => (int)$this->gateway_id->value(),
|
|
||||||
'session_id' => (int)$this->session_id->value(),
|
|
||||||
'event_type' => (string)$this->event_type->value(),
|
|
||||||
'payload' => (array)($this->payload_json->value() ?? []),
|
|
||||||
'created_at' => (string)$this->created_at->value(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace objects;
|
|
||||||
|
|
||||||
use classes\db;
|
|
||||||
use classes\edge_gateway_schema_bootstrap;
|
|
||||||
use classes\object_property;
|
|
||||||
use traits\db_object_t;
|
|
||||||
|
|
||||||
class edge_gateway_shell_sessions_o extends db
|
|
||||||
{
|
|
||||||
use db_object_t;
|
|
||||||
|
|
||||||
public object_property $gateway_id;
|
|
||||||
public object_property $reason;
|
|
||||||
public object_property $approval_status;
|
|
||||||
public object_property $session_token_hash;
|
|
||||||
public object_property $requested_by;
|
|
||||||
public object_property $approved_by;
|
|
||||||
public object_property $approved_at;
|
|
||||||
public object_property $expires_at;
|
|
||||||
public object_property $opened_at;
|
|
||||||
public object_property $closed_at;
|
|
||||||
public object_property $transcript_text;
|
|
||||||
public object_property $metadata_json;
|
|
||||||
public object_property $created_at;
|
|
||||||
public object_property $updated_at;
|
|
||||||
public object_property $deleted_at;
|
|
||||||
|
|
||||||
public function structure(): void
|
|
||||||
{
|
|
||||||
edge_gateway_schema_bootstrap::ensureTables();
|
|
||||||
$this->setTable('edge_gateway_shell_sessions');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getObjectProperties(): void
|
|
||||||
{
|
|
||||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
|
||||||
$this->reason = new object_property($this->table, $this->id, 'reason', 'text', false);
|
|
||||||
$this->approval_status = new object_property($this->table, $this->id, 'approval_status', 'string', false);
|
|
||||||
$this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false);
|
|
||||||
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
|
||||||
$this->approved_by = new object_property($this->table, $this->id, 'approved_by', 'int', false);
|
|
||||||
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
|
|
||||||
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
|
|
||||||
$this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false);
|
|
||||||
$this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', false);
|
|
||||||
$this->transcript_text = new object_property($this->table, $this->id, 'transcript_text', 'text', false);
|
|
||||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
|
||||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
|
||||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
|
||||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function objectChanged(): void
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function asArray(): array
|
|
||||||
{
|
|
||||||
$this->requireSelected();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => (int)$this->id,
|
|
||||||
'gateway_id' => (int)$this->gateway_id->value(),
|
|
||||||
'reason' => (string)$this->reason->value(),
|
|
||||||
'approval_status' => (string)$this->approval_status->value(),
|
|
||||||
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
|
||||||
'approved_by' => $this->approved_by->value() === null ? null : (int)$this->approved_by->value(),
|
|
||||||
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
|
|
||||||
'expires_at' => (string)$this->expires_at->value(),
|
|
||||||
'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(),
|
|
||||||
'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(),
|
|
||||||
'transcript_text' => $this->transcript_text->value() === null ? null : (string)$this->transcript_text->value(),
|
|
||||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
|
||||||
'created_at' => (string)$this->created_at->value(),
|
|
||||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,8 @@ use classes\db;
|
|||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\gatewayapi;
|
use classes\gatewayapi;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
|
use classes\order_bookings_counts_cache;
|
||||||
|
use classes\order_bookings_list_cache;
|
||||||
use classes\pdf_generator;
|
use classes\pdf_generator;
|
||||||
use classes\slack;
|
use classes\slack;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -290,7 +292,12 @@ class order_bookings_o extends db
|
|||||||
|
|
||||||
public function objectChanged(): void
|
public function objectChanged(): void
|
||||||
{
|
{
|
||||||
//TODO: Add cache invalidation
|
try {
|
||||||
|
order_bookings_list_cache::clearAll();
|
||||||
|
order_bookings_counts_cache::clearAll();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Cache invalidation must never break order-booking writes.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -569,4 +576,68 @@ class order_bookings_o extends db
|
|||||||
return count($order_ids);
|
return count($order_ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int|string>|null $departmentIds
|
||||||
|
* @return array{past:int,current:int,future:int}
|
||||||
|
*/
|
||||||
|
public function getPendingBookingCounts(?array $departmentIds = null, ?int $customerNumber = null, ?\DateTimeImmutable $reference = null): array
|
||||||
|
{
|
||||||
|
global /** @var db $db */
|
||||||
|
$db;
|
||||||
|
|
||||||
|
$normalizedDepartmentIds = [];
|
||||||
|
if (is_array($departmentIds)) {
|
||||||
|
foreach ($departmentIds as $departmentId) {
|
||||||
|
$normalizedDepartmentId = (int)$departmentId;
|
||||||
|
if ($normalizedDepartmentId > 0) {
|
||||||
|
$normalizedDepartmentIds[] = $normalizedDepartmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$normalizedDepartmentIds = array_values(array_unique($normalizedDepartmentIds));
|
||||||
|
if ($normalizedDepartmentIds === []) {
|
||||||
|
return [
|
||||||
|
'past' => 0,
|
||||||
|
'current' => 0,
|
||||||
|
'future' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = $reference ?? new \DateTimeImmutable('now');
|
||||||
|
$todayStart = $now->setTime(0, 0, 0);
|
||||||
|
$todayEnd = $now->setTime(23, 59, 59);
|
||||||
|
|
||||||
|
$todayStartSql = $db->escape_string($todayStart->format('Y-m-d H:i:s'));
|
||||||
|
$todayEndSql = $db->escape_string($todayEnd->format('Y-m-d H:i:s'));
|
||||||
|
|
||||||
|
$whereClauses = [
|
||||||
|
'`deleted_at` IS NULL',
|
||||||
|
"(`order_id` IS NULL OR `order_id` = 0 OR TRIM(CAST(`order_id` AS CHAR)) = '')",
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($normalizedDepartmentIds !== []) {
|
||||||
|
$whereClauses[] = '`department` IN (' . implode(', ', array_map('intval', $normalizedDepartmentIds)) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customerNumber !== null && $customerNumber > 0) {
|
||||||
|
$whereClauses[] = '`customer_number` = ' . (int)$customerNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN `datetime` < '{$todayStartSql}' THEN 1 ELSE 0 END), 0) AS `past`,
|
||||||
|
COALESCE(SUM(CASE WHEN `datetime` >= '{$todayStartSql}' AND `datetime` <= '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `current`,
|
||||||
|
COALESCE(SUM(CASE WHEN `datetime` > '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `future`
|
||||||
|
FROM `order_bookings`
|
||||||
|
WHERE " . implode(' AND ', $whereClauses);
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$row = $db->fetch_assoc($result);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'past' => max(0, (int)($row['past'] ?? 0)),
|
||||||
|
'current' => max(0, (int)($row['current'] ?? 0)),
|
||||||
|
'future' => max(0, (int)($row['future'] ?? 0)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,6 +318,16 @@ class orders_o extends db
|
|||||||
self::objectChanged();
|
self::objectChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function clearStripeInvoicing(): void
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
$this->stripe_module_orders->clear();
|
||||||
|
self::objectChanged();
|
||||||
|
}
|
||||||
|
|
||||||
public function addArray(array $order_array): orders_o
|
public function addArray(array $order_array): orders_o
|
||||||
{
|
{
|
||||||
global $db, $response;
|
global $db, $response;
|
||||||
@@ -375,10 +385,11 @@ class orders_o extends db
|
|||||||
* @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used.
|
* @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used.
|
||||||
* @throws Exception If the order is not selected
|
* @throws Exception If the order is not selected
|
||||||
*/
|
*/
|
||||||
public function assignToInvoiceCollection(int $invoiceCollectionId = null): void
|
public function assignToInvoiceCollection(int $invoiceCollectionId = null, bool $notifyChanges = true): void
|
||||||
{
|
{
|
||||||
// If the invoice collection id is not set, get the default invoice collection id
|
// If the invoice collection id is not set, get the default invoice collection id
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
$previousInvoiceCollectionId = (int)$this->invoice_collection_id->value();
|
||||||
// Get the customer
|
// Get the customer
|
||||||
$customer = new users_o();
|
$customer = new users_o();
|
||||||
$customer_id = (int)$this->customer_id->value();
|
$customer_id = (int)$this->customer_id->value();
|
||||||
@@ -391,7 +402,16 @@ class orders_o extends db
|
|||||||
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
|
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
|
||||||
// Assign the order to the invoice collection
|
// Assign the order to the invoice collection
|
||||||
$this->invoice_collection_id->set($invoiceCollectionId);
|
$this->invoice_collection_id->set($invoiceCollectionId);
|
||||||
self::objectChanged();
|
if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) {
|
||||||
|
$previousCollection = new collected_order_invoices_o();
|
||||||
|
$previousCollection->select($previousInvoiceCollectionId);
|
||||||
|
if ($previousCollection->exists()) {
|
||||||
|
$previousCollection->objectChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($notifyChanges) {
|
||||||
|
self::objectChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1378,14 +1398,91 @@ class orders_o extends db
|
|||||||
public function hasWashCertificateAttached(): bool
|
public function hasWashCertificateAttached(): bool
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
// Check if the order has a wash certificate attached
|
return $this->listWashCertificateAttachmentIds() !== [];
|
||||||
$attachments = $this->listAttachments();
|
}
|
||||||
foreach ( $attachments as $attachment ) {
|
|
||||||
if ($attachment->isWashCertificate()) {
|
/**
|
||||||
return true; // Wash certificate found
|
* @return int[]
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
protected function listWashCertificateAttachmentIds(): array
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$rawObjectType = trim((string)$this->table, '`');
|
||||||
|
$objectTypes = array_values(array_unique([
|
||||||
|
$db->escape_string($rawObjectType),
|
||||||
|
$db->escape_string('`' . $rawObjectType . '`'),
|
||||||
|
]));
|
||||||
|
$quotedObjectTypes = "'" . implode("','", $objectTypes) . "'";
|
||||||
|
$objectId = (int)$this->id;
|
||||||
|
$sql = "SELECT id, content
|
||||||
|
FROM object_attachments
|
||||||
|
WHERE object_type IN ($quotedObjectTypes)
|
||||||
|
AND object_id = $objectId
|
||||||
|
AND deleted_at IS NULL";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentIds = [];
|
||||||
|
while ($row = $db->fetch_assoc($result)) {
|
||||||
|
$content = json_decode((string)($row['content'] ?? ''), true);
|
||||||
|
$other = is_array($content) ? ($content['other'] ?? null) : null;
|
||||||
|
if (is_string($other) && strtolower($other) === 'wash_certificate') {
|
||||||
|
$attachmentIds[] = (int)($row['id'] ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false; // No wash certificate product found in the order items
|
|
||||||
|
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function regenerateAttachedWashCertificate(): bool
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
if (!$this->hasWashCertificateAttached()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeAttachedWashCertificates();
|
||||||
|
$this->generateWashCertificate(
|
||||||
|
$this->getSafetySealValue(),
|
||||||
|
$this->resolveWashCertificateOperator(),
|
||||||
|
$this->resolveWashCertificateDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->hasWashCertificateAttached();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
protected function removeAttachedWashCertificates(): int
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
|
||||||
|
$attachmentIds = $this->listWashCertificateAttachmentIds();
|
||||||
|
if ($attachmentIds === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$escapedIds = array_map(static fn(int $id): int => (int)$id, $attachmentIds);
|
||||||
|
$idList = implode(',', $escapedIds);
|
||||||
|
$sql = "UPDATE object_attachments
|
||||||
|
SET deleted_at = NOW()
|
||||||
|
WHERE id IN ($idList)
|
||||||
|
AND deleted_at IS NULL";
|
||||||
|
$db->query($sql);
|
||||||
|
|
||||||
|
return count($escapedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1439,6 +1536,42 @@ class orders_o extends db
|
|||||||
return self::normalizeSafetySealValue($this->safety_seal->value());
|
return self::normalizeSafetySealValue($this->safety_seal->value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function resolveWashCertificateOperator(): ?string
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
|
||||||
|
$cashierId = (int)$this->cashier_id->value();
|
||||||
|
if ($cashierId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cashier = (new users_o())->select($cashierId);
|
||||||
|
if (!$cashier->exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = trim((string)$cashier->display_name->value());
|
||||||
|
return $displayName === '' ? null : $displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function resolveWashCertificateDate(): string
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
|
||||||
|
$candidate = $this->completed_at->value() ?: $this->created_at->value();
|
||||||
|
if (is_string($candidate) && trim($candidate) !== '') {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -1482,17 +1615,27 @@ class orders_o extends db
|
|||||||
if (!$department->exists()) {
|
if (!$department->exists()) {
|
||||||
throw new Exception('Department not found');
|
throw new Exception('Department not found');
|
||||||
}
|
}
|
||||||
// Get branding for the department
|
// Branding is optional; fall back to the department values when it is not configured.
|
||||||
$branding = (new branding_o())->select((int)$department->branding->value());
|
$branding = null;
|
||||||
if (!$branding->exists()) {
|
$brandingId = (int)($department->branding->value() ?? 0);
|
||||||
throw new Exception('Branding not found');
|
if ($brandingId > 0) {
|
||||||
|
$selectedBranding = (new branding_o())->select($brandingId);
|
||||||
|
if ($selectedBranding->exists()) {
|
||||||
|
$branding = $selectedBranding;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Get the customer for the order
|
// Get the customer for the order
|
||||||
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||||
$department_array = $department->asArray();
|
$department_array = $department->asArray();
|
||||||
$department_array['branding'] = $branding->asArray();
|
|
||||||
$customer_array = $customer->asArray();
|
|
||||||
$order_array = $this->asArray();
|
$order_array = $this->asArray();
|
||||||
|
$branding_array = $branding?->asArray() ?? [];
|
||||||
|
$customer_number = (int)$customer->customer_number->value();
|
||||||
|
$customer_name = trim((string)$customer->display_name->value());
|
||||||
|
if ($customer_name === '') {
|
||||||
|
$customer_name = (string)($customer_number > 0 ? $customer_number : $this->customer_id->value());
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer_address = '-';
|
||||||
// Format the date as 17:35 02-12-2025
|
// Format the date as 17:35 02-12-2025
|
||||||
$date = ($date instanceof DateTime ? $date : ($date !== null ? new DateTime($date) : new DateTime()));
|
$date = ($date instanceof DateTime ? $date : ($date !== null ? new DateTime($date) : new DateTime()));
|
||||||
$date_formatted = ($date instanceof DateTime ? $date->format('d-m-Y') : date('d-m-Y'));
|
$date_formatted = ($date instanceof DateTime ? $date->format('d-m-Y') : date('d-m-Y'));
|
||||||
@@ -1526,10 +1669,10 @@ class orders_o extends db
|
|||||||
'time' => $time_formatted,
|
'time' => $time_formatted,
|
||||||
'carried_out_by' => ($operator ?? null),
|
'carried_out_by' => ($operator ?? null),
|
||||||
'department_id' => $department->id,
|
'department_id' => $department->id,
|
||||||
'department_name' => $department_array['branding']['name'] ?: $department_array['name'],
|
'department_name' => ($branding_array['name'] ?? null) ?: $department_array['name'],
|
||||||
'department_address' => $department_array['branding']['address'] ?: $department_array['description'],
|
'department_address' => ($branding_array['address'] ?? null) ?: $department_array['description'],
|
||||||
'customer_name' => $customer->getCustomerName($customer_array['customer_number']),
|
'customer_name' => $customer_name,
|
||||||
'customer_address' => $customer->getCustomerEcocomicData($customer_array['customer_number'])->economic_customer->address ?: '-',
|
'customer_address' => $customer_address,
|
||||||
'wash_type' => 'ORDER_WASH'
|
'wash_type' => 'ORDER_WASH'
|
||||||
])
|
])
|
||||||
->getHtml()
|
->getHtml()
|
||||||
|
|||||||
@@ -93,4 +93,19 @@ class stripe_module_orders_o extends db
|
|||||||
return (new stripe())->invoice->retrieve($this->invoice_id->value());
|
return (new stripe())->invoice->retrieve($this->invoice_id->value());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
public function clear(): void
|
||||||
|
{
|
||||||
|
if (!$this->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteForOrder(int $order_id): void
|
||||||
|
{
|
||||||
|
$record = (new self())->select($order_id);
|
||||||
|
$record->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -2195,6 +2195,17 @@ paths:
|
|||||||
two_factor_enabled:
|
two_factor_enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Indicates if 2FA is enabled for this account
|
description: Indicates if 2FA is enabled for this account
|
||||||
|
runtime_config:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
economic:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
transaction_draft_customer_number:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
additionalProperties: false
|
||||||
|
additionalProperties: true
|
||||||
'400':
|
'400':
|
||||||
$ref: '#/components/responses/BadRequest'
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
@@ -12041,12 +12052,13 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
module: { type: string, enum: [economic] }
|
module: { type: string, enum: [economic] }
|
||||||
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] }
|
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] }
|
||||||
type: { type: string, enum: [string, int] }
|
type: { type: string, enum: [string, int] }
|
||||||
value:
|
value:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: integer
|
- type: integer
|
||||||
|
nullable: true
|
||||||
required: [module, variable, type, value]
|
required: [module, variable, type, value]
|
||||||
|
|
||||||
RecaptchaConfigEntry:
|
RecaptchaConfigEntry:
|
||||||
|
|||||||
@@ -0,0 +1,770 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
fwrite(STDERR, "This agent must run from the CLI.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AgentConfig
|
||||||
|
{
|
||||||
|
public function __construct(public array $data, public string $path)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function load(string $path): self
|
||||||
|
{
|
||||||
|
if (!is_file($path)) {
|
||||||
|
throw new RuntimeException('Missing config file: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)file_get_contents($path), true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Invalid config file: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($decoded, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return array_key_exists($key, $this->data) ? $this->data[$key] : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$this->data[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->path, json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HttpJsonClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $baseUrl)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $path, array $payload, int $timeoutSeconds = 30): ?array
|
||||||
|
{
|
||||||
|
$response = $this->requestJson(
|
||||||
|
'POST',
|
||||||
|
rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/'),
|
||||||
|
$payload,
|
||||||
|
$timeoutSeconds
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response['decoded'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJson(string $url, int $timeoutSeconds = 10): array
|
||||||
|
{
|
||||||
|
$response = $this->requestJson('GET', $url, null, $timeoutSeconds);
|
||||||
|
return $response['decoded'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(string $url, int $timeoutSeconds = 60): string
|
||||||
|
{
|
||||||
|
$response = $this->requestRaw('GET', $url, null, $timeoutSeconds, ['Accept: */*']);
|
||||||
|
return $response['body'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
|
||||||
|
{
|
||||||
|
$headers = ['Accept: application/json'];
|
||||||
|
if ($payload !== null) {
|
||||||
|
$headers[] = 'Content-Type: application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->requestRaw($method, $url, $payload, $timeoutSeconds, $headers);
|
||||||
|
$decoded = json_decode($response['body'], true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Invalid JSON response from ' . $method . ' ' . $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response['decoded'] = $decoded;
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestRaw(
|
||||||
|
string $method,
|
||||||
|
string $url,
|
||||||
|
?array $payload,
|
||||||
|
int $timeoutSeconds,
|
||||||
|
array $headers
|
||||||
|
): array {
|
||||||
|
$responseHeaders = [];
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $timeoutSeconds,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => min(10, $timeoutSeconds),
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_HEADERFUNCTION => static function ($curl, string $headerLine) use (&$responseHeaders): int {
|
||||||
|
$trimmed = trim($headerLine);
|
||||||
|
if ($trimmed !== '') {
|
||||||
|
$responseHeaders[] = $trimmed;
|
||||||
|
}
|
||||||
|
return strlen($headerLine);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($payload !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES));
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
throw new RuntimeException(sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status >= 400) {
|
||||||
|
$preview = substr(preg_replace('/\s+/', ' ', (string)$raw) ?? '', 0, 240);
|
||||||
|
$headerPreview = implode('; ', array_slice($responseHeaders, 0, 8));
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'%s %s returned HTTP %d. Headers: %s. Body preview: %s',
|
||||||
|
$method,
|
||||||
|
$url,
|
||||||
|
$status,
|
||||||
|
$headerPreview !== '' ? $headerPreview : 'none',
|
||||||
|
$preview !== '' ? $preview : 'empty'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'body' => (string)$raw,
|
||||||
|
'headers' => $responseHeaders,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Logger
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $logFile)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function info(string $message): void
|
||||||
|
{
|
||||||
|
$this->write('INFO', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warning(string $message): void
|
||||||
|
{
|
||||||
|
$this->write('WARNING', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function error(string $message): void
|
||||||
|
{
|
||||||
|
$this->write('ERROR', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(string $level, string $message): void
|
||||||
|
{
|
||||||
|
$line = '[' . date('c') . "] {$level} {$message}\n";
|
||||||
|
file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||||
|
fwrite($level === 'ERROR' ? STDERR : STDOUT, $line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TruckwashEdgeAgent
|
||||||
|
{
|
||||||
|
private AgentConfig $config;
|
||||||
|
private HttpJsonClient $http;
|
||||||
|
private Logger $logger;
|
||||||
|
private string $installDir;
|
||||||
|
private string $runtimeDir;
|
||||||
|
private string $statePath;
|
||||||
|
private string $lastOperationSnapshotPath;
|
||||||
|
private string $lastHeartbeatMarkerPath;
|
||||||
|
private int $lastHeartbeatAt = 0;
|
||||||
|
private string $agentInstanceId;
|
||||||
|
|
||||||
|
public function __construct(string $configPath)
|
||||||
|
{
|
||||||
|
$this->config = AgentConfig::load($configPath);
|
||||||
|
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
|
||||||
|
$this->installDir = rtrim((string)$this->config->get('installDir', dirname($configPath)), DIRECTORY_SEPARATOR);
|
||||||
|
$this->runtimeDir = $this->installDir . DIRECTORY_SEPARATOR . 'runtime';
|
||||||
|
@mkdir($this->runtimeDir, 0777, true);
|
||||||
|
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
|
||||||
|
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
|
||||||
|
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
|
||||||
|
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
|
||||||
|
$this->agentInstanceId = $this->ensureAgentInstanceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->logger->info('Truckwash PHP edge agent starting.');
|
||||||
|
$this->recoverPreviousOperationState();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
$this->ensureClaimed();
|
||||||
|
$this->heartbeat();
|
||||||
|
$processedManagementOperation = $this->processManagementOperation();
|
||||||
|
if (!$processedManagementOperation) {
|
||||||
|
$this->processCommandQueue();
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->logger->error($throwable->getMessage());
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(250000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureClaimed(): void
|
||||||
|
{
|
||||||
|
if ($this->config->get('gatewayId') && $this->config->get('agentToken')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->http->post('/edge-agent/claim', [
|
||||||
|
'token' => (string)$this->config->get('installToken'),
|
||||||
|
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||||
|
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
|
||||||
|
'metadata' => [
|
||||||
|
'runtime' => 'php-cli',
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = $response['data'] ?? [];
|
||||||
|
$gateway = $payload['gateway'] ?? [];
|
||||||
|
$installedVersion = (string)$this->config->get('installedVersion', 'php-agent-v1');
|
||||||
|
$this->config->set('gatewayId', $gateway['id'] ?? null);
|
||||||
|
$this->config->set('agentToken', $payload['agent_token'] ?? null);
|
||||||
|
$this->config->set('installedVersion', $installedVersion);
|
||||||
|
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
|
||||||
|
$this->config->set('agentInstanceId', $this->agentInstanceId);
|
||||||
|
$this->config->save();
|
||||||
|
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function heartbeat(bool $force = false, array $metadata = []): void
|
||||||
|
{
|
||||||
|
$interval = (int)$this->config->get('heartbeatIntervalSeconds', 15);
|
||||||
|
if (!$force && (time() - $this->lastHeartbeatAt) < max(5, $interval)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gatewayId = (int)$this->config->get('gatewayId');
|
||||||
|
if ($gatewayId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationState = $this->readOperationState();
|
||||||
|
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'status' => 'ONLINE',
|
||||||
|
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||||
|
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
|
||||||
|
'target_version' => (string)$this->config->get('targetVersion', $this->config->get('installedVersion', 'php-agent-v1')),
|
||||||
|
'metadata' => array_merge([
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
'current_operation' => $operationState,
|
||||||
|
'system_metrics' => [
|
||||||
|
'memory_usage_bytes' => memory_get_usage(true),
|
||||||
|
'memory_peak_bytes' => memory_get_peak_usage(true),
|
||||||
|
'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : [],
|
||||||
|
],
|
||||||
|
], $metadata),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->lastHeartbeatAt = time();
|
||||||
|
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'at' => date('c'),
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processCommandQueue(): void
|
||||||
|
{
|
||||||
|
$gatewayId = (int)$this->config->get('gatewayId');
|
||||||
|
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
|
||||||
|
]);
|
||||||
|
$command = $response['data'] ?? null;
|
||||||
|
if (!is_array($command) || empty($command['id'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = (int)$command['id'];
|
||||||
|
$type = (string)($command['command_type'] ?? $command['commandType'] ?? '');
|
||||||
|
$request = is_array($command['payload'] ?? null) ? (array)$command['payload'] : [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = match ($type) {
|
||||||
|
'DISCOVER_SHELLY' => $this->runDiscovery(),
|
||||||
|
'GET_RELAY_STATUS' => $this->readRelayStatus($request),
|
||||||
|
'SET_RELAY_STATE' => $this->switchRelay($request),
|
||||||
|
default => throw new RuntimeException('Unsupported command type: ' . $type),
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'ok' => true,
|
||||||
|
'result' => $result,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'ok' => false,
|
||||||
|
'error' => $throwable->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processManagementOperation(): bool
|
||||||
|
{
|
||||||
|
$gatewayId = (int)$this->config->get('gatewayId');
|
||||||
|
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
]);
|
||||||
|
$operation = $response['data'] ?? null;
|
||||||
|
if (!is_array($operation) || empty($operation['id'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationId = (int)$operation['id'];
|
||||||
|
$type = (string)($operation['type'] ?? '');
|
||||||
|
$request = is_array($operation['request'] ?? null) ? (array)$operation['request'] : [];
|
||||||
|
$this->persistOperationState([
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => 'IN_PROGRESS',
|
||||||
|
'stage' => 'claimed',
|
||||||
|
'progress' => 5,
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
'started_at' => date('c'),
|
||||||
|
'request' => $request,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->emitOperationProgress(
|
||||||
|
$gatewayId,
|
||||||
|
$operationId,
|
||||||
|
'OPERATION_AGENT_STARTED',
|
||||||
|
'PHP edge agent started processing the operation',
|
||||||
|
10,
|
||||||
|
['type' => $type, 'agent_instance_id' => $this->agentInstanceId],
|
||||||
|
'starting'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = match ($type) {
|
||||||
|
'DISCOVERY' => $this->runManagementDiscovery($gatewayId, $operationId, $request),
|
||||||
|
'UPDATE' => $this->runUpdate($gatewayId, $operationId, $request),
|
||||||
|
'UNINSTALL' => $this->runUninstall($gatewayId, $operationId, $request),
|
||||||
|
default => throw new RuntimeException('Unsupported operation type: ' . $type),
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'ok' => true,
|
||||||
|
'result' => $result,
|
||||||
|
]);
|
||||||
|
$this->snapshotOperationState('COMPLETED', ['result' => $result]);
|
||||||
|
$this->clearOperationState();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$errorCode = $this->classifyManagementError($throwable, $type);
|
||||||
|
$this->postOperationEvent($gatewayId, $operationId, [
|
||||||
|
'level' => 'ERROR',
|
||||||
|
'code' => $errorCode,
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'ok' => false,
|
||||||
|
'error_code' => $errorCode,
|
||||||
|
'error_message' => $throwable->getMessage(),
|
||||||
|
]);
|
||||||
|
$this->snapshotOperationState('FAILED', [
|
||||||
|
'error_code' => $errorCode,
|
||||||
|
'error_message' => $throwable->getMessage(),
|
||||||
|
]);
|
||||||
|
$this->clearOperationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void
|
||||||
|
{
|
||||||
|
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events', [
|
||||||
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
|
'level' => (string)($payload['level'] ?? 'INFO'),
|
||||||
|
'code' => $payload['code'] ?? null,
|
||||||
|
'message' => (string)($payload['message'] ?? 'Operation event'),
|
||||||
|
'context' => is_array($payload['context'] ?? null) ? (array)$payload['context'] : [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emitOperationProgress(
|
||||||
|
int $gatewayId,
|
||||||
|
int $operationId,
|
||||||
|
string $code,
|
||||||
|
string $message,
|
||||||
|
int $progress,
|
||||||
|
array $context = [],
|
||||||
|
?string $stage = null
|
||||||
|
): void {
|
||||||
|
$operationState = $this->readOperationState();
|
||||||
|
if ($operationState !== null) {
|
||||||
|
$operationState['progress'] = max(0, min(100, $progress));
|
||||||
|
$operationState['stage'] = $stage ?? ($operationState['stage'] ?? 'running');
|
||||||
|
$operationState['updated_at'] = date('c');
|
||||||
|
$this->persistOperationState($operationState);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->heartbeat(true, [
|
||||||
|
'current_operation' => $this->readOperationState(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postOperationEvent($gatewayId, $operationId, [
|
||||||
|
'level' => 'INFO',
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
'context' => array_merge($context, [
|
||||||
|
'progress' => max(0, min(100, $progress)),
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runDiscovery(): array
|
||||||
|
{
|
||||||
|
return ['inventory' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array
|
||||||
|
{
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COLLECTING', 'Collecting local gateway inventory', 35, [], 'discovering');
|
||||||
|
$hostname = gethostname() ?: 'truckwash-edge';
|
||||||
|
$fallbackInventory = [[
|
||||||
|
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
|
||||||
|
'local_ip' => gethostbyname($hostname),
|
||||||
|
'model' => 'TruckWash Edge Gateway',
|
||||||
|
'channel_count' => 1,
|
||||||
|
'online' => true,
|
||||||
|
'capabilities' => [
|
||||||
|
'local_discovery' => true,
|
||||||
|
'relay_commands' => true,
|
||||||
|
'gateway_management_v2' => true,
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'hostname' => $hostname,
|
||||||
|
'runtime' => 'php-cli',
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
],
|
||||||
|
]];
|
||||||
|
|
||||||
|
$inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== []
|
||||||
|
? (array)$request['inventory']
|
||||||
|
: $fallbackInventory;
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COMPLETED', 'Inventory collected', 90, [
|
||||||
|
'device_count' => count($inventory),
|
||||||
|
], 'finishing');
|
||||||
|
|
||||||
|
return ['inventory' => $inventory];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runUpdate(int $gatewayId, int $operationId, array $request): array
|
||||||
|
{
|
||||||
|
$targetVersion = trim((string)($request['target_version'] ?? $request['targetVersion'] ?? ''));
|
||||||
|
if ($targetVersion === '') {
|
||||||
|
throw new RuntimeException('Update request is missing target version');
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentArtifactUrl = trim((string)($request['artifactUrl'] ?? ''));
|
||||||
|
$serviceUnitUrl = trim((string)($request['serviceUnitUrl'] ?? ''));
|
||||||
|
$agentArtifactSha = trim((string)($request['artifactSha256'] ?? ''));
|
||||||
|
$serviceUnitSha = trim((string)($request['serviceUnitSha256'] ?? ''));
|
||||||
|
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated update request', 20, [
|
||||||
|
'target_version' => $targetVersion,
|
||||||
|
], 'validating');
|
||||||
|
|
||||||
|
$changedFiles = [];
|
||||||
|
if ($agentArtifactUrl !== '') {
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_AGENT', 'Downloading PHP edge agent artifact', 40, [
|
||||||
|
'url' => $agentArtifactUrl,
|
||||||
|
], 'downloading-agent');
|
||||||
|
$changedFiles[] = $this->downloadToFileAtomic(
|
||||||
|
$agentArtifactUrl,
|
||||||
|
$this->installDir . DIRECTORY_SEPARATOR . 'agent.php',
|
||||||
|
$agentArtifactSha
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($serviceUnitUrl !== '') {
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_SERVICE', 'Downloading service unit', 65, [
|
||||||
|
'url' => $serviceUnitUrl,
|
||||||
|
], 'downloading-service');
|
||||||
|
$changedFiles[] = $this->downloadToFileAtomic(
|
||||||
|
$serviceUnitUrl,
|
||||||
|
$this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service',
|
||||||
|
$serviceUnitSha
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_WRITE_CONFIG', 'Writing staged update metadata', 85, [], 'writing-config');
|
||||||
|
$this->config->set('installedVersion', $targetVersion);
|
||||||
|
$this->config->set('targetVersion', $targetVersion);
|
||||||
|
$this->config->set('lastStagedUpdate', [
|
||||||
|
'target_version' => $targetVersion,
|
||||||
|
'staged_at' => date('c'),
|
||||||
|
'changed_files' => $changedFiles,
|
||||||
|
]);
|
||||||
|
$this->config->save();
|
||||||
|
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Artifacts staged successfully', 95, [
|
||||||
|
'changed_files' => $changedFiles,
|
||||||
|
], 'staged');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'installed_version' => $targetVersion,
|
||||||
|
'target_version' => $targetVersion,
|
||||||
|
'restart_required' => true,
|
||||||
|
'service_name' => (string)$this->config->get('serviceName', 'truckwash-edge-agent.service'),
|
||||||
|
'changed_files' => $changedFiles,
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runUninstall(int $gatewayId, int $operationId, array $request): array
|
||||||
|
{
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_PREPARE', 'Preparing uninstall manifest', 40, [], 'preparing-uninstall');
|
||||||
|
$manifestPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall-plan.json';
|
||||||
|
$manifest = [
|
||||||
|
'service_name' => (string)($request['service_name'] ?? $this->config->get('serviceName', 'truckwash-edge-agent.service')),
|
||||||
|
'install_dir' => (string)($request['install_dir'] ?? $this->installDir),
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
|
];
|
||||||
|
file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
file_put_contents($this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall.flag', date('c') . PHP_EOL);
|
||||||
|
$this->config->set('pendingUninstall', $manifest);
|
||||||
|
$this->config->save();
|
||||||
|
|
||||||
|
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_READY', 'Gateway marked for controlled uninstall', 90, [
|
||||||
|
'manifest_path' => $manifestPath,
|
||||||
|
], 'uninstall-ready');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uninstalled' => true,
|
||||||
|
'service_name' => $manifest['service_name'],
|
||||||
|
'install_dir' => $manifest['install_dir'],
|
||||||
|
'manual_cleanup_required' => true,
|
||||||
|
'cleanup_manifest_path' => $manifestPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readRelayStatus(array $request): array
|
||||||
|
{
|
||||||
|
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
||||||
|
$channel = (int)($request['channel'] ?? 0);
|
||||||
|
return $this->fetchShellyState($localIp, $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function switchRelay(array $request): array
|
||||||
|
{
|
||||||
|
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
||||||
|
$channel = (int)($request['channel'] ?? 0);
|
||||||
|
$on = (bool)($request['on'] ?? false);
|
||||||
|
|
||||||
|
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
|
||||||
|
try {
|
||||||
|
$this->http->getJson($rpcUrl, 8);
|
||||||
|
} catch (Throwable) {
|
||||||
|
$legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off');
|
||||||
|
$this->http->getJson($legacyUrl, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fetchShellyState($localIp, $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
|
||||||
|
{
|
||||||
|
$directory = dirname($path);
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
@mkdir($directory, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$temporaryPath = $path . '.download';
|
||||||
|
$backupPath = $path . '.bak';
|
||||||
|
$raw = $this->http->download($url, 60);
|
||||||
|
file_put_contents($temporaryPath, $raw);
|
||||||
|
|
||||||
|
if ($expectedSha256 !== '') {
|
||||||
|
$actualSha = hash_file('sha256', $temporaryPath);
|
||||||
|
if (!is_string($actualSha) || !hash_equals(strtolower($expectedSha256), strtolower($actualSha))) {
|
||||||
|
@unlink($temporaryPath);
|
||||||
|
throw new RuntimeException('Artifact checksum mismatch for ' . basename($path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($backupPath)) {
|
||||||
|
@unlink($backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($path) && !@rename($path, $backupPath)) {
|
||||||
|
@unlink($temporaryPath);
|
||||||
|
throw new RuntimeException('Could not create backup for ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@rename($temporaryPath, $path)) {
|
||||||
|
if (is_file($backupPath)) {
|
||||||
|
@rename($backupPath, $path);
|
||||||
|
}
|
||||||
|
@unlink($temporaryPath);
|
||||||
|
throw new RuntimeException('Could not promote downloaded artifact for ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'url' => $url,
|
||||||
|
'backup_path' => is_file($backupPath) ? $backupPath : null,
|
||||||
|
'sha256' => hash_file('sha256', $path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function classifyManagementError(Throwable $throwable, string $type): string
|
||||||
|
{
|
||||||
|
$message = strtolower(trim($throwable->getMessage()));
|
||||||
|
if (str_contains($message, 'token') || str_contains($message, 'credential')) {
|
||||||
|
return 'EDGE_GATEWAY_INVALID_TOKEN';
|
||||||
|
}
|
||||||
|
if (str_contains($message, 'timeout') || str_contains($message, 'http 5')) {
|
||||||
|
return 'EDGE_GATEWAY_OPERATION_TIMEOUT';
|
||||||
|
}
|
||||||
|
if ($type === 'UPDATE' && (str_contains($message, 'version') || str_contains($message, 'checksum'))) {
|
||||||
|
return 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'EDGE_GATEWAY_VALIDATION_FAILED';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchShellyState(string $localIp, int $channel): array
|
||||||
|
{
|
||||||
|
if ($localIp === '') {
|
||||||
|
throw new RuntimeException('Missing Shelly IP address');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $this->http->getJson(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel), 8);
|
||||||
|
return [
|
||||||
|
'online' => true,
|
||||||
|
'on' => (bool)($payload['output'] ?? false),
|
||||||
|
'output' => (bool)($payload['output'] ?? false),
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
$payload = $this->http->getJson(sprintf('http://%s/relay/%d', $localIp, $channel), 8);
|
||||||
|
return [
|
||||||
|
'online' => true,
|
||||||
|
'on' => (bool)($payload['ison'] ?? false),
|
||||||
|
'output' => (bool)($payload['ison'] ?? false),
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureAgentInstanceId(): string
|
||||||
|
{
|
||||||
|
$configured = trim((string)$this->config->get('agentInstanceId', ''));
|
||||||
|
if ($configured !== '') {
|
||||||
|
return substr($configured, 0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
$generated = sprintf(
|
||||||
|
'%s-%s',
|
||||||
|
preg_replace('/[^A-Za-z0-9\-]+/', '-', gethostname() ?: 'truckwash-edge') ?: 'truckwash-edge',
|
||||||
|
substr(bin2hex(random_bytes(6)), 0, 12)
|
||||||
|
);
|
||||||
|
$this->config->set('agentInstanceId', $generated);
|
||||||
|
$this->config->save();
|
||||||
|
return $generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recoverPreviousOperationState(): void
|
||||||
|
{
|
||||||
|
$state = $this->readOperationState();
|
||||||
|
if ($state === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->warning(sprintf(
|
||||||
|
'Recovered previous unfinished operation snapshot: operation %s (%s) last stage %s.',
|
||||||
|
(string)($state['operation_id'] ?? 'unknown'),
|
||||||
|
(string)($state['type'] ?? 'unknown'),
|
||||||
|
(string)($state['stage'] ?? 'unknown')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistOperationState(array $state): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshotOperationState(string $status, array $extra = []): void
|
||||||
|
{
|
||||||
|
$state = $this->readOperationState() ?? [];
|
||||||
|
$state['status'] = $status;
|
||||||
|
$state['finished_at'] = date('c');
|
||||||
|
$state = array_merge($state, $extra);
|
||||||
|
file_put_contents($this->lastOperationSnapshotPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearOperationState(): void
|
||||||
|
{
|
||||||
|
if (is_file($this->statePath)) {
|
||||||
|
@unlink($this->statePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readOperationState(): ?array
|
||||||
|
{
|
||||||
|
if (!is_file($this->statePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)file_get_contents($this->statePath), true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = null;
|
||||||
|
foreach ($argv as $index => $argument) {
|
||||||
|
if ($argument === '--config' && isset($argv[$index + 1])) {
|
||||||
|
$configPath = $argv[$index + 1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($configPath) || trim($configPath) === '') {
|
||||||
|
fwrite(STDERR, "Usage: agent.php --config /path/to/config.json\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new TruckwashEdgeAgent($configPath))->run();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TruckWash Edge Agent
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/truckwash-edge-agent
|
||||||
|
ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -116,6 +116,12 @@ class authRoute
|
|||||||
try {
|
try {
|
||||||
$cached = redis->get_auth_session($token);
|
$cached = redis->get_auth_session($token);
|
||||||
if (is_array($cached)) {
|
if (is_array($cached)) {
|
||||||
|
$cached = $this->appendRuntimeConfig($cached);
|
||||||
|
try {
|
||||||
|
redis->cache_auth_session($token, $cached, 60);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Best-effort caching only
|
||||||
|
}
|
||||||
$response->success($cached);
|
$response->success($cached);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -131,6 +137,7 @@ class authRoute
|
|||||||
|
|
||||||
$user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray();
|
$user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray();
|
||||||
$user_data['two_factor_enabled'] = $user->isTwoFactorEnabled();
|
$user_data['two_factor_enabled'] = $user->isTwoFactorEnabled();
|
||||||
|
$user_data = $this->appendRuntimeConfig($user_data);
|
||||||
|
|
||||||
// Cache the session payload briefly to reduce DB load on hot paths
|
// Cache the session payload briefly to reduce DB load on hot paths
|
||||||
try {
|
try {
|
||||||
@@ -810,4 +817,18 @@ class authRoute
|
|||||||
|
|
||||||
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
|
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function appendRuntimeConfig(array $payload): array
|
||||||
|
{
|
||||||
|
$payload['runtime_config'] = array_replace_recursive(
|
||||||
|
is_array($payload['runtime_config'] ?? null) ? $payload['runtime_config'] : [],
|
||||||
|
[
|
||||||
|
'economic' => [
|
||||||
|
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace routes;
|
|||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
use classes\department_outside_hours_statistics_service;
|
use classes\department_outside_hours_statistics_service;
|
||||||
use classes\workfeed;
|
use classes\workfeed;
|
||||||
|
use classes\workfeed_shift_time_resolver;
|
||||||
use customers\economicCustomers;
|
use customers\economicCustomers;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
@@ -1654,53 +1655,7 @@ class departmentDailyReportsRoute
|
|||||||
|
|
||||||
private function calculateShiftOvertimeHoursInRange(array $record, DateTime $range_start, DateTime $range_end_exclusive): float
|
private function calculateShiftOvertimeHoursInRange(array $record, DateTime $range_start, DateTime $range_end_exclusive): float
|
||||||
{
|
{
|
||||||
$shift_start = $this->firstShiftDateTimeFromPaths($record, [
|
return workfeed_shift_time_resolver::calculateOvertimeHoursInRange($record, $range_start, $range_end_exclusive);
|
||||||
'actualStart',
|
|
||||||
'actualStartTime',
|
|
||||||
'clockIn',
|
|
||||||
'clockInTime',
|
|
||||||
'start',
|
|
||||||
'startTime',
|
|
||||||
'from',
|
|
||||||
'approval.originalStart',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$scheduled_end = $this->lastShiftDateTimeFromPaths($record, [
|
|
||||||
'end',
|
|
||||||
'endTime',
|
|
||||||
'to',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$effective_end = $this->lastShiftDateTimeFromPaths($record, [
|
|
||||||
'actualEnd',
|
|
||||||
'actualEndTime',
|
|
||||||
'clockOut',
|
|
||||||
'clockOutTime',
|
|
||||||
'end',
|
|
||||||
'endTime',
|
|
||||||
'to',
|
|
||||||
'approval.originalEnd',
|
|
||||||
'approval.end',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($shift_start === null || $scheduled_end === null || $effective_end === null) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effective_end = $this->resolveEffectiveShiftEnd($record, $shift_start, $effective_end);
|
|
||||||
$scheduled_end_ts = $scheduled_end->getTimestamp();
|
|
||||||
$effective_end_ts = $effective_end->getTimestamp();
|
|
||||||
if ($effective_end_ts <= $scheduled_end_ts) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
|
|
||||||
$overtime_end_ts = min($effective_end_ts, $range_end_exclusive->getTimestamp());
|
|
||||||
if ($overtime_end_ts <= $overtime_start_ts) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\economic;
|
||||||
use classes\economic_transfer_queue;
|
use classes\economic_transfer_queue;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\router;
|
use classes\router;
|
||||||
@@ -45,6 +46,11 @@ class economicInvoiceRoute
|
|||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
$response->error('Order not found', 404);
|
$response->error('Order not found', 404);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
$this->assertOrderCanBeExportedToEconomic((int)$order_id);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->isEconomicTransferQueueAvailable()) {
|
if (!$this->isEconomicTransferQueueAvailable()) {
|
||||||
try {
|
try {
|
||||||
@@ -158,6 +164,11 @@ class economicInvoiceRoute
|
|||||||
if (!$customer->exists()) {
|
if (!$customer->exists()) {
|
||||||
$response->error('Customer not found', 404);
|
$response->error('Customer not found', 404);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
}
|
||||||
$economic_module_orders = (new economic_module_orders())->getByOrderId((int)$order_id);
|
$economic_module_orders = (new economic_module_orders())->getByOrderId((int)$order_id);
|
||||||
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
|
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
|
||||||
$response->error('No economic invoice draft found', 404);
|
$response->error('No economic invoice draft found', 404);
|
||||||
@@ -365,6 +376,7 @@ class economicInvoiceRoute
|
|||||||
if (!$customer->exists()) {
|
if (!$customer->exists()) {
|
||||||
throw new \Exception('Customer not found');
|
throw new \Exception('Customer not found');
|
||||||
}
|
}
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
|
||||||
|
|
||||||
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
|
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
|
||||||
$economic_invoice_draft = new economic_invoice_draft_mo();
|
$economic_invoice_draft = new economic_invoice_draft_mo();
|
||||||
@@ -449,6 +461,7 @@ class economicInvoiceRoute
|
|||||||
if (!$customer->exists()) {
|
if (!$customer->exists()) {
|
||||||
throw new \Exception('Customer not found');
|
throw new \Exception('Customer not found');
|
||||||
}
|
}
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
|
||||||
|
|
||||||
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
|
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
|
||||||
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
|
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
|
||||||
@@ -622,4 +635,17 @@ class economicInvoiceRoute
|
|||||||
$response->error('Economic transfer queue is unavailable in this deployment', 503);
|
$response->error('Economic transfer queue is unavailable in this deployment', 503);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
private function assertOrderCanBeExportedToEconomic(int $order_id): void
|
||||||
|
{
|
||||||
|
$customer = (new orders_o())->getCustomerByOrderId($order_id);
|
||||||
|
if (!$customer->exists()) {
|
||||||
|
throw new \Exception('Customer not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\edge_gateway_install_service;
|
||||||
use classes\edge_gateway_manager;
|
use classes\edge_gateway_manager;
|
||||||
|
use classes\edge_gateway_operation_exception;
|
||||||
|
use classes\edge_gateway_operation_service;
|
||||||
|
use classes\edge_gateway_registry_service;
|
||||||
|
use classes\edge_gateway_view_service;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use objects\logs_o;
|
use Exception;
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class edgeGatewaysRoute
|
class edgeGatewaysRoute
|
||||||
@@ -14,244 +19,223 @@ class edgeGatewaysRoute
|
|||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->get('/edge-gateways', function () {
|
$this->get('/edge-gateways', fn() => $this->handleListGateways(), [
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
|
|
||||||
if ($departmentId !== null && $departmentId > 0) {
|
|
||||||
$this->requireDepartmentAccess((int)$departmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->listGateways($departmentId));
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Manage department edge gateways for local Shelly control',
|
'modules_shelly_config' => 'Manage department edge gateways for local Shelly control',
|
||||||
]);
|
]);
|
||||||
|
$this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
|
||||||
$this->get('/edge-gateways/{id}', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
$response->success($gateway);
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'View department edge gateway detail',
|
'modules_shelly_config' => 'View department edge gateway detail',
|
||||||
]);
|
]);
|
||||||
|
$this->put('/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
|
||||||
$this->post('/edge-gateways/install-token', function () {
|
'modules_shelly_config' => 'Update edge gateway metadata and primary assignment',
|
||||||
global /** @var response $response */ $response;
|
]);
|
||||||
$this->requirePermission('modules_shelly_config');
|
$this->get('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [
|
||||||
|
'modules_shelly_config' => 'List edge gateway operations',
|
||||||
self::requireParameters(['department_id']);
|
]);
|
||||||
$departmentId = (int)self::getParameter('department_id');
|
$this->post('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
|
||||||
self::requireParameterIntPositive($departmentId, 'department_id');
|
'modules_shelly_config' => 'Queue an edge gateway operation',
|
||||||
$this->requireDepartmentAccess($departmentId);
|
]);
|
||||||
|
$this->get('/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
|
||||||
$user = (new authentication())->get_user();
|
'modules_shelly_config' => 'List edge gateway operation events',
|
||||||
$response->success(
|
]);
|
||||||
(new edge_gateway_manager())->createInstallToken(
|
$this->post('/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [
|
||||||
$departmentId,
|
'modules_shelly_config' => 'Rotate edge gateway credentials',
|
||||||
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
|
]);
|
||||||
$user ? (int)$user->id : null
|
$this->post('/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
|
||||||
),
|
|
||||||
201
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
|
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
|
||||||
]);
|
]);
|
||||||
|
$this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
|
||||||
$this->post('/edge-gateways/{id}/discovery', function () {
|
'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway',
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->queueDiscovery($gatewayId, $user ? (int)$user->id : null));
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Trigger a Shelly LAN discovery job on an edge gateway',
|
|
||||||
]);
|
]);
|
||||||
|
$this->put('/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [
|
||||||
$this->put('/edge-gateways/{id}/bindings', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
self::requireParameters(['bindings']);
|
|
||||||
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->setRelayBindings(
|
|
||||||
$gatewayId,
|
|
||||||
(array)self::getParameter('bindings'),
|
|
||||||
$user ? (int)$user->id : null
|
|
||||||
));
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway',
|
'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway',
|
||||||
]);
|
]);
|
||||||
|
$this->delete('/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [
|
||||||
$this->post('/edge-gateways/{id}/update-jobs', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
self::requireParameters(['target_version']);
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->queueUpdate(
|
|
||||||
$gatewayId,
|
|
||||||
(string)self::getParameter('target_version'),
|
|
||||||
self::isParametersSet(['release_channel']) ? (string)self::getParameter('release_channel') : edge_gateway_manager::DEFAULT_RELEASE_CHANNEL,
|
|
||||||
$user ? (int)$user->id : null
|
|
||||||
), 201);
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Queue an automatic edge gateway update',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post('/edge-gateways/{id}/uninstall', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->queueUninstall($gatewayId, $user ? (int)$user->id : null), 202);
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Queue an edge gateway uninstall on the Raspberry Pi',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post('/edge-gateways/{id}/shell-sessions', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
self::requireParameters(['reason']);
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
self::requireMinLength('reason', 5);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->createShellSessionRequest(
|
|
||||||
$gatewayId,
|
|
||||||
(string)self::getParameter('reason'),
|
|
||||||
[
|
|
||||||
'cols' => self::isParametersSet(['cols']) ? (int)self::getParameter('cols') : null,
|
|
||||||
'rows' => self::isParametersSet(['rows']) ? (int)self::getParameter('rows') : null,
|
|
||||||
],
|
|
||||||
$user ? (int)$user->id : null
|
|
||||||
), 201);
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Approve a break-glass root shell on an edge gateway',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get('/edge-gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleShellSessionEvents(), [
|
|
||||||
'modules_shelly_config' => 'Poll root shell events for an edge gateway',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/input', fn() => $this->handleShellSessionInput(), [
|
|
||||||
'modules_shelly_config' => 'Send terminal input to an edge gateway root shell',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/resize', fn() => $this->handleShellSessionResize(), [
|
|
||||||
'modules_shelly_config' => 'Resize an edge gateway root shell terminal',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/close', fn() => $this->handleShellSessionClose(), [
|
|
||||||
'modules_shelly_config' => 'Close an approved edge gateway root shell session',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post('/edge-gateways/{id}/rotate-credentials', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->rotateGatewayCredentials($gatewayId, $user ? (int)$user->id : null));
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Rotate edge gateway agent credentials',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->delete('/edge-gateways/{id}', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->deleteGateway($gatewayId, $user ? (int)$user->id : null));
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Delete an edge gateway registration',
|
'modules_shelly_config' => 'Delete an edge gateway registration',
|
||||||
]);
|
]);
|
||||||
|
$this->post('/departments/{id}/gateway-cutover', fn() => $this->handleDepartmentCutover(), [
|
||||||
$this->post('/departments/{id}/gateway-cutover', function () {
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$departmentId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($departmentId, 'id');
|
|
||||||
$this->requireDepartmentAccess($departmentId);
|
|
||||||
self::requireParameters(['transport_mode']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success((new edge_gateway_manager())->setDepartmentTransportMode(
|
|
||||||
$departmentId,
|
|
||||||
(string)self::getParameter('transport_mode'),
|
|
||||||
$user ? (int)$user->id : null
|
|
||||||
));
|
|
||||||
}, [
|
|
||||||
'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways',
|
'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||||
$this->get('/edge-agent/artifacts/agent.mjs', fn() => $this->renderAgentArtifact('agent.mjs', 'application/javascript; charset=utf-8'));
|
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||||
$this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8'));
|
$this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service'));
|
||||||
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
|
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
|
||||||
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
|
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
|
||||||
|
$this->post('/edge-agent/gateways/{id}/operations/next', fn() => $this->handleAgentOperationNext());
|
||||||
|
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/events', fn() => $this->handleAgentOperationEvent());
|
||||||
|
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
|
||||||
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
||||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
||||||
$this->post('/edge-agent/gateways/{id}/shell-actions/poll', fn() => $this->handleAgentShellActionPoll());
|
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
||||||
$this->post('/edge-agent/gateways/{id}/shell-actions/{actionId}/result', fn() => $this->handleAgentShellActionResult());
|
}
|
||||||
$this->post('/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleAgentShellEvents());
|
|
||||||
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
|
private function handleListGateways(): void
|
||||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellValidate());
|
{
|
||||||
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
|
global /** @var response $response */ $response;
|
||||||
$this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose());
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
|
||||||
|
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
|
||||||
|
$view = trim((string)$this->fromQuery('view'));
|
||||||
|
if ($departmentId !== null && $departmentId > 0) {
|
||||||
|
$this->requireDepartmentAccess($departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateways = $this->views()->listGateways($departmentId, $view !== 'summary');
|
||||||
|
$response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways));
|
||||||
|
$response->success($gateways);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayDetail(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
$response->success($this->requireGatewayAccess((int)$this->fromRoute('id')));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayUpdate(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
self::requireParameters(['label', 'is_primary']);
|
||||||
|
self::requireType(self::getParameter('label'), self::TYPE_STRING());
|
||||||
|
self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());
|
||||||
|
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
|
||||||
|
$response->success($this->registry()->updateGatewayMetadata($gatewayId, [
|
||||||
|
'label' => (string)self::getParameter('label'),
|
||||||
|
'is_primary' => (bool)self::getParameter('is_primary'),
|
||||||
|
], $this->actorUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayOperationsList(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
$response->success($this->operations()->listOperations($gatewayId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayOperationCreate(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
self::requireParameters(['type', 'request']);
|
||||||
|
self::requireType(self::getParameter('type'), self::TYPE_STRING());
|
||||||
|
self::requireType(self::getParameter('request'), self::TYPE_ARRAY());
|
||||||
|
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$operation = $this->operations()->queueOperation(
|
||||||
|
$gatewayId,
|
||||||
|
(string)self::getParameter('type'),
|
||||||
|
(array)self::getParameter('request'),
|
||||||
|
$this->actorUserId()
|
||||||
|
);
|
||||||
|
$response->success([
|
||||||
|
'operation' => $operation,
|
||||||
|
'gateway' => $this->views()->getGateway($gatewayId),
|
||||||
|
], 201);
|
||||||
|
} catch (edge_gateway_operation_exception $exception) {
|
||||||
|
$response->error([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'error_code' => $exception->errorCode,
|
||||||
|
], $exception->status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayOperationEvents(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$operationId = (int)$this->fromRoute('operationId');
|
||||||
|
self::requireParameterIntPositive($operationId, 'operationId');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayCredentialRotate(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
$response->success($this->operations()->rotateCredentials($gatewayId, $this->actorUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleInstallTokenCreate(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
self::requireParameters(['department_id']);
|
||||||
|
|
||||||
|
$departmentId = (int)self::getParameter('department_id');
|
||||||
|
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||||
|
$this->requireDepartmentAccess($departmentId);
|
||||||
|
|
||||||
|
$response->success(
|
||||||
|
$this->registry()->createInstallToken(
|
||||||
|
$departmentId,
|
||||||
|
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
|
||||||
|
$this->actorUserId()
|
||||||
|
),
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayDiscovery(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
|
||||||
|
$this->operations()->queueDiscoveryOperation($gatewayId, $this->actorUserId());
|
||||||
|
$response->success($this->views()->getGateway($gatewayId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleBindingsUpdate(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
self::requireParameters(['bindings']);
|
||||||
|
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
|
||||||
|
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
$this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), $this->actorUserId());
|
||||||
|
$response->success($this->views()->getGateway($gatewayId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleGatewayDelete(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$this->requireGatewayAccess($gatewayId);
|
||||||
|
$response->success($this->registry()->deleteGateway($gatewayId, $this->actorUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleDepartmentCutover(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requirePermission('modules_shelly_config');
|
||||||
|
self::requireParameters(['transport_mode']);
|
||||||
|
$departmentId = (int)$this->fromRoute('id');
|
||||||
|
self::requireParameterIntPositive($departmentId, 'id');
|
||||||
|
$this->requireDepartmentAccess($departmentId);
|
||||||
|
$response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), $this->actorUserId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderInstallScript(): void
|
private function renderInstallScript(): void
|
||||||
{
|
{
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$token = trim((string)$this->fromQuery('token'));
|
$token = trim((string)$this->fromQuery('token'));
|
||||||
if ($token === '') {
|
if ($token === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -260,7 +244,7 @@ class edgeGatewaysRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: text/x-shellscript; charset=utf-8');
|
header('Content-Type: text/x-shellscript; charset=utf-8');
|
||||||
echo $manager->buildInstallScript($token);
|
echo $this->install()->buildInstallScript($token);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,21 +256,20 @@ class edgeGatewaysRoute
|
|||||||
$response->error('Missing token', 400);
|
$response->error('Missing token', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->verifyInstallToken($token));
|
$response->success($this->install()->verifyInstallToken($token));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderAgentArtifact(string $fileName, string $contentType): void
|
private function renderArtifact(string $fileName): void
|
||||||
{
|
{
|
||||||
$artifactPath = dirname(WD, 3) . '/services/edge-agent/dist/' . $fileName;
|
try {
|
||||||
if (!is_file($artifactPath)) {
|
header('Content-Type: ' . $this->install()->contentType($fileName));
|
||||||
|
echo $this->install()->readArtifact($fileName);
|
||||||
|
exit;
|
||||||
|
} catch (Exception $exception) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo 'Missing edge agent artifact';
|
echo $exception->getMessage();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: ' . $contentType);
|
|
||||||
echo file_get_contents($artifactPath);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentClaim(): void
|
private function handleAgentClaim(): void
|
||||||
@@ -294,40 +277,103 @@ class edgeGatewaysRoute
|
|||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
self::requireParameters(['token']);
|
self::requireParameters(['token']);
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
$response->success((new edge_gateway_manager())->claimGateway(
|
|
||||||
(string)$payload['token'],
|
$response->success(
|
||||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
$this->registry()->claimGateway(
|
||||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
(string)$payload['token'],
|
||||||
(array)($payload['metadata'] ?? [])
|
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||||
), 201);
|
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||||
|
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||||
|
),
|
||||||
|
201
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentHeartbeat(): void
|
private function handleAgentHeartbeat(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
$response->success($this->registry()->recordHeartbeat($gatewayId, $this->requireAgentToken($payload), $payload));
|
||||||
if ($token === '') {
|
}
|
||||||
$response->error('Missing edge gateway agent token', 401);
|
|
||||||
|
private function handleAgentOperationNext(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response->success($this->operations()->claimNextOperation(
|
||||||
|
$gatewayId,
|
||||||
|
$this->requireAgentToken($payload),
|
||||||
|
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS,
|
||||||
|
isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null
|
||||||
|
));
|
||||||
|
} catch (edge_gateway_operation_exception $exception) {
|
||||||
|
$response->error([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'error_code' => $exception->errorCode,
|
||||||
|
], $exception->status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAgentOperationEvent(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$operationId = (int)$this->fromRoute('operationId');
|
||||||
|
self::requireParameterIntPositive($operationId, 'operationId');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response->success($this->operations()->appendAgentOperationEvent(
|
||||||
|
$gatewayId,
|
||||||
|
$operationId,
|
||||||
|
$this->requireAgentToken($payload),
|
||||||
|
$payload
|
||||||
|
));
|
||||||
|
} catch (edge_gateway_operation_exception $exception) {
|
||||||
|
$response->error([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'error_code' => $exception->errorCode,
|
||||||
|
], $exception->status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAgentOperationComplete(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$operationId = (int)$this->fromRoute('operationId');
|
||||||
|
self::requireParameterIntPositive($operationId, 'operationId');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response->success($this->operations()->completeAgentOperation(
|
||||||
|
$gatewayId,
|
||||||
|
$operationId,
|
||||||
|
$this->requireAgentToken($payload),
|
||||||
|
$payload
|
||||||
|
));
|
||||||
|
} catch (edge_gateway_operation_exception $exception) {
|
||||||
|
$response->error([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'error_code' => $exception->errorCode,
|
||||||
|
], $exception->status);
|
||||||
}
|
}
|
||||||
$response->success((new edge_gateway_manager())->recordHeartbeat($gatewayId, $token, $payload));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentCommandPoll(): void
|
private function handleAgentCommandPoll(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
$response->success($this->manager()->pollCommand(
|
||||||
if ($token === '') {
|
$gatewayId,
|
||||||
$response->error('Missing edge gateway agent token', 401);
|
$this->requireAgentToken($payload),
|
||||||
}
|
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS
|
||||||
|
));
|
||||||
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS;
|
|
||||||
$response->success((new edge_gateway_manager())->pollCommand($gatewayId, $token, $waitSeconds));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentCommandResult(): void
|
private function handleAgentCommandResult(): void
|
||||||
@@ -335,256 +381,83 @@ class edgeGatewaysRoute
|
|||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
$jobId = (int)$this->fromRoute('jobId');
|
$jobId = (int)$this->fromRoute('jobId');
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
self::requireParameterIntPositive($jobId, 'jobId');
|
self::requireParameterIntPositive($jobId, 'jobId');
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
$response->success($this->manager()->submitCommandResult(
|
||||||
if ($token === '') {
|
|
||||||
$response->error('Missing edge gateway agent token', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->submitCommandResult(
|
|
||||||
$gatewayId,
|
$gatewayId,
|
||||||
$jobId,
|
$jobId,
|
||||||
$token,
|
$this->requireAgentToken($payload),
|
||||||
(bool)($payload['ok'] ?? false),
|
(bool)($payload['ok'] ?? false),
|
||||||
isset($payload['payload']) && is_array($payload['payload']) ? (array)$payload['payload'] : [],
|
isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [],
|
||||||
isset($payload['error']) ? (string)$payload['error'] : null
|
isset($payload['error']) ? (string)$payload['error'] : null
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleShellSessionEvents(): void
|
private function handleAgentPresence(): void
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
$sessionId = (int)$this->fromRoute('sessionId');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
self::requireParameterIntPositive($sessionId, 'sessionId');
|
|
||||||
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$afterId = max(0, (int)$this->fromQuery('after_id'));
|
|
||||||
$waitSeconds = (int)$this->fromQuery('wait_seconds');
|
|
||||||
if ($waitSeconds <= 0) {
|
|
||||||
$waitSeconds = edge_gateway_manager::SHELL_EVENT_POLL_TIMEOUT_SECONDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->success($manager->pollShellSessionEvents($gatewayId, $sessionId, $afterId, $waitSeconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleShellSessionInput(): void
|
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
self::requireParameters(['data']);
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
$sessionId = (int)$this->fromRoute('sessionId');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
self::requireParameterIntPositive($sessionId, 'sessionId');
|
|
||||||
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->enqueueShellInput(
|
|
||||||
$gatewayId,
|
|
||||||
$sessionId,
|
|
||||||
(string)self::getParameter('data'),
|
|
||||||
$user ? (int)$user->id : null
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleShellSessionResize(): void
|
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
self::requireParameters(['cols', 'rows']);
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
$sessionId = (int)$this->fromRoute('sessionId');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
self::requireParameterIntPositive($sessionId, 'sessionId');
|
|
||||||
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->enqueueShellResize(
|
|
||||||
$gatewayId,
|
|
||||||
$sessionId,
|
|
||||||
(int)self::getParameter('cols'),
|
|
||||||
(int)self::getParameter('rows'),
|
|
||||||
$user ? (int)$user->id : null
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleShellSessionClose(): void
|
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requirePermission('modules_shelly_config');
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
$sessionId = (int)$this->fromRoute('sessionId');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
self::requireParameterIntPositive($sessionId, 'sessionId');
|
|
||||||
|
|
||||||
$manager = new edge_gateway_manager();
|
|
||||||
$gateway = $manager->getGateway($gatewayId);
|
|
||||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
$response->success($manager->requestShellSessionClose(
|
|
||||||
$gatewayId,
|
|
||||||
$sessionId,
|
|
||||||
$user ? (int)$user->id : null
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleAgentShellActionPoll(): void
|
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
|
||||||
if ($token === '') {
|
|
||||||
$response->error('Missing edge gateway agent token', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::SHELL_ACTION_POLL_TIMEOUT_SECONDS;
|
$this->requireAgentToken($payload);
|
||||||
$response->success((new edge_gateway_manager())->pollShellAction($gatewayId, $token, $waitSeconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleAgentShellActionResult(): void
|
$response->success($this->manager()->recordBrokerPresence(
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
$actionId = (int)$this->fromRoute('actionId');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
self::requireParameterIntPositive($actionId, 'actionId');
|
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
|
||||||
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
|
||||||
if ($token === '') {
|
|
||||||
$response->error('Missing edge gateway agent token', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->submitShellActionResult(
|
|
||||||
$gatewayId,
|
$gatewayId,
|
||||||
$actionId,
|
isset($payload['status']) ? (string)$payload['status'] : 'disconnected',
|
||||||
$token,
|
|
||||||
(bool)($payload['ok'] ?? false),
|
|
||||||
isset($payload['error']) ? (string)$payload['error'] : null
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleAgentShellEvents(): void
|
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
$sessionId = (int)$this->fromRoute('sessionId');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
self::requireParameterIntPositive($sessionId, 'sessionId');
|
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
|
||||||
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
|
||||||
if ($token === '') {
|
|
||||||
$response->error('Missing edge gateway agent token', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$events = isset($payload['events']) && is_array($payload['events'])
|
|
||||||
? (array)$payload['events']
|
|
||||||
: (isset($payload['event']) && is_array($payload['event']) ? [(array)$payload['event']] : []);
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->recordShellSessionEvents(
|
|
||||||
$gatewayId,
|
|
||||||
$sessionId,
|
|
||||||
$token,
|
|
||||||
$events
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleBrokerGatewayValidate(): void
|
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requireBrokerSharedSecret();
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$payload = self::getParametersAsArray();
|
|
||||||
self::requireParameters(['token']);
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->validateBrokerAgentConnection(
|
|
||||||
$gatewayId,
|
|
||||||
(string)$payload['token']
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleBrokerShellValidate(): void
|
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requireBrokerSharedSecret();
|
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
|
||||||
self::requireParameters(['token']);
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->validateShellSessionToken((string)$payload['token']));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleBrokerGatewayPresence(): void
|
|
||||||
{
|
|
||||||
global /** @var response $response */ $response;
|
|
||||||
$this->requireBrokerSharedSecret();
|
|
||||||
|
|
||||||
$gatewayId = (int)$this->fromRoute('id');
|
|
||||||
self::requireParameterIntPositive($gatewayId, 'id');
|
|
||||||
$payload = self::getParametersAsArray();
|
|
||||||
self::requireParameters(['status']);
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->recordBrokerPresence(
|
|
||||||
$gatewayId,
|
|
||||||
(string)$payload['status'],
|
|
||||||
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
|
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
|
||||||
isset($payload['reason']) ? (string)$payload['reason'] : null,
|
isset($payload['reason']) ? (string)$payload['reason'] : null,
|
||||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleBrokerShellSessionClose(): void
|
private function requireGatewayAccess(int $gatewayId): array
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
self::requireParameterIntPositive($gatewayId, 'id');
|
||||||
$this->requireBrokerSharedSecret();
|
$gateway = $this->views()->getGateway($gatewayId);
|
||||||
|
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
||||||
$payload = self::getParametersAsArray();
|
return $gateway;
|
||||||
self::requireParameters(['token']);
|
|
||||||
|
|
||||||
$response->success((new edge_gateway_manager())->closeShellSession(
|
|
||||||
(string)$payload['token'],
|
|
||||||
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
|
|
||||||
isset($payload['reason']) ? (string)$payload['reason'] : 'broker_closed'
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireBrokerSharedSecret(): void
|
private function requireAgentToken(array $payload): string
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
$expected = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
||||||
if ($expected === '') {
|
if ($token === '') {
|
||||||
$response->error('Edge broker secret is not configured', 503);
|
$response->error('Missing edge gateway agent token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? $this->fromRequest('broker_secret')));
|
return $token;
|
||||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
}
|
||||||
$response->error('Forbidden', 403);
|
|
||||||
}
|
private function actorUserId(): ?int
|
||||||
|
{
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
return $user ? (int)$user->id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function views(): edge_gateway_view_service
|
||||||
|
{
|
||||||
|
return new edge_gateway_view_service();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registry(): edge_gateway_registry_service
|
||||||
|
{
|
||||||
|
return new edge_gateway_registry_service();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manager(): edge_gateway_manager
|
||||||
|
{
|
||||||
|
return new edge_gateway_manager();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operations(): edge_gateway_operation_service
|
||||||
|
{
|
||||||
|
return new edge_gateway_operation_service($this->manager());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function install(): edge_gateway_install_service
|
||||||
|
{
|
||||||
|
return new edge_gateway_install_service();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,30 @@ class moduleStripeRoute
|
|||||||
// Check if the order exists
|
// Check if the order exists
|
||||||
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
|
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
|
||||||
$order->requireSelected();
|
$order->requireSelected();
|
||||||
|
if ($order->stripe_module_orders->exists()) {
|
||||||
|
$existingInvoice = [];
|
||||||
|
$shouldBlockInvoiceCreation = true;
|
||||||
|
try {
|
||||||
|
$existingInvoice = $order->stripe_module_orders->asArray();
|
||||||
|
$retrievedInvoice = $order->stripe_module_orders->retrievePaymentLink();
|
||||||
|
$existingStatus = (string)($retrievedInvoice->status ?? '');
|
||||||
|
$isTerminalInvoiceState = (bool)($retrievedInvoice->paid ?? false) === true
|
||||||
|
|| in_array($existingStatus, ['paid', 'void', 'uncollectible', 'deleted'], true);
|
||||||
|
$shouldBlockInvoiceCreation = !$isTerminalInvoiceState;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$shouldBlockInvoiceCreation = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldBlockInvoiceCreation) {
|
||||||
|
$response->error([
|
||||||
|
'message' => 'A Stripe payment link is already active for this order.',
|
||||||
|
'code' => 'stripe_invoice_exists',
|
||||||
|
'stripeModuleOrders' => $existingInvoice,
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->clearStripeInvoicing();
|
||||||
|
}
|
||||||
// Log the action
|
// Log the action
|
||||||
(new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User sent an invoice');
|
(new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User sent an invoice');
|
||||||
// Create a customer account, if it doesn't exist
|
// Create a customer account, if it doesn't exist
|
||||||
@@ -141,6 +165,56 @@ class moduleStripeRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Modules > Stripe > Cancel Invoice */
|
||||||
|
$this->delete('/modules/stripe/invoice', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('modules_stripe_invoice_send');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('modules_stripe', 'global', 0, 0, 'MODULES_STRIPE', 'User tried to cancel a Stripe invoice without a valid session');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::requireParameters(['order_id']);
|
||||||
|
self::requireType((int)self::fromRequest('order_id'), self::TYPE_INT());
|
||||||
|
self::requireMinLength('order_id', 1);
|
||||||
|
self::requireMaxLength('order_id', 255);
|
||||||
|
|
||||||
|
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
|
||||||
|
$order->requireSelected();
|
||||||
|
|
||||||
|
if (!$order->stripe_module_orders->exists()) {
|
||||||
|
$response->success([
|
||||||
|
'stripeModuleOrders' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice = $order->stripe_module_orders->retrievePaymentLink();
|
||||||
|
if ((bool)($invoice->paid ?? false) === true) {
|
||||||
|
$response->error([
|
||||||
|
'message' => 'A paid Stripe payment link cannot be cancelled.',
|
||||||
|
'code' => 'stripe_invoice_paid',
|
||||||
|
'stripeModuleOrders' => $order->stripe_module_orders->asArray(),
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceStatus = (string)($invoice->status ?? '');
|
||||||
|
if ($invoiceStatus !== 'void' && $invoiceStatus !== 'uncollectible' && $invoiceStatus !== 'deleted') {
|
||||||
|
(new stripe())->invoice->void((string)$order->stripe_module_orders->invoice_id->value());
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->clearStripeInvoicing();
|
||||||
|
(new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User cancelled a Stripe invoice');
|
||||||
|
|
||||||
|
$response->success([
|
||||||
|
'stripeModuleOrders' => [],
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'modules_stripe_invoice_send' => 'Send invoice'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
self::get('/modules/stripe/terminal/readers', function () {
|
self::get('/modules/stripe/terminal/readers', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('modules_stripe_terminal_readers_list');
|
self::requirePermission('modules_stripe_terminal_readers_list');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use classes\response;
|
|||||||
use classes\router;
|
use classes\router;
|
||||||
use classes\weatherapi;
|
use classes\weatherapi;
|
||||||
use classes\workfeed;
|
use classes\workfeed;
|
||||||
|
use classes\workfeed_shift_time_resolver;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
@@ -1634,7 +1635,12 @@ class moduleWeatherAPIRoute
|
|||||||
return $update_time;
|
return $update_time;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string|array $workfeed_department_ids, DateTime $slot_start): float
|
private function calculateWorkfeedEmployeeHoursForHour(
|
||||||
|
array $shifts,
|
||||||
|
string|array $workfeed_department_ids,
|
||||||
|
DateTime $slot_start,
|
||||||
|
?DateTime $occurred_until = null
|
||||||
|
): float
|
||||||
{
|
{
|
||||||
$department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
|
$department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
|
||||||
$department_id_lookup = [];
|
$department_id_lookup = [];
|
||||||
@@ -1652,6 +1658,7 @@ class moduleWeatherAPIRoute
|
|||||||
$slot_end->add(new DateInterval('PT1H'));
|
$slot_end->add(new DateInterval('PT1H'));
|
||||||
$slot_start_ts = $slot_start->getTimestamp();
|
$slot_start_ts = $slot_start->getTimestamp();
|
||||||
$slot_end_ts = $slot_end->getTimestamp();
|
$slot_end_ts = $slot_end->getTimestamp();
|
||||||
|
$occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp();
|
||||||
|
|
||||||
$hours = 0.0;
|
$hours = 0.0;
|
||||||
foreach ($shifts as $shift) {
|
foreach ($shifts as $shift) {
|
||||||
@@ -1660,36 +1667,13 @@ class moduleWeatherAPIRoute
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$record = self::normalizeWorkfeedRecord($shift);
|
$timing = workfeed_shift_time_resolver::resolveShiftTiming($shift);
|
||||||
$shift_start = self::firstShiftDateTimeFromPaths($record, [
|
if ($timing === null) {
|
||||||
'actualStart',
|
|
||||||
'actualStartTime',
|
|
||||||
'clockIn',
|
|
||||||
'clockInTime',
|
|
||||||
'start',
|
|
||||||
'startTime',
|
|
||||||
'from',
|
|
||||||
'approval.originalStart',
|
|
||||||
]);
|
|
||||||
$shift_end = self::lastShiftDateTimeFromPaths($record, [
|
|
||||||
'actualEnd',
|
|
||||||
'actualEndTime',
|
|
||||||
'clockOut',
|
|
||||||
'clockOutTime',
|
|
||||||
'end',
|
|
||||||
'endTime',
|
|
||||||
'to',
|
|
||||||
'approval.originalEnd',
|
|
||||||
'approval.end',
|
|
||||||
]);
|
|
||||||
if ($shift_start === null || $shift_end === null) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$shift_end = self::resolveEffectiveShiftEnd($record, $shift_start, $shift_end);
|
$shift_start_ts = $timing['actualStart']->getTimestamp();
|
||||||
|
$shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts);
|
||||||
$shift_start_ts = $shift_start->getTimestamp();
|
|
||||||
$shift_end_ts = $shift_end->getTimestamp();
|
|
||||||
if ($shift_end_ts <= $shift_start_ts) {
|
if ($shift_end_ts <= $shift_start_ts) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\order_bookings_counts_cache;
|
||||||
|
use classes\order_bookings_list_cache;
|
||||||
use classes\redis;
|
use classes\redis;
|
||||||
use Exception;
|
use Exception;
|
||||||
use modules\subusers\helpers\subusers_permission_node_key;
|
use modules\subusers\helpers\subusers_permission_node_key;
|
||||||
@@ -132,17 +134,41 @@ class orderBookingRoute
|
|||||||
*/
|
*/
|
||||||
$object = new order_bookings_o(); // New object for listing
|
$object = new order_bookings_o(); // New object for listing
|
||||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||||
$response->success($object->listObjectsWithPaginationIfSet(
|
$forcedFilters = $object->forceRestrictFilters([
|
||||||
|
...($has_permission_other && $user !== false ? [
|
||||||
|
'department' => $user->getGroup()->getDepartments()
|
||||||
|
] : []),
|
||||||
|
...(!$has_permission_other && $effectiveCustomer !== null ? [
|
||||||
|
'customer_number' => [(int)$effectiveCustomer]
|
||||||
|
] : [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cacheTtl = order_bookings_list_cache::getTtl();
|
||||||
|
$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;
|
||||||
|
if ($cacheKey !== null) {
|
||||||
|
$cachedPayload = order_bookings_list_cache::getPayload($cacheKey);
|
||||||
|
if ($cachedPayload !== null) {
|
||||||
|
$response->rawJson($cachedPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $object->listObjectsWithPaginationIfSet(
|
||||||
function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); },
|
function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); },
|
||||||
$object->forceRestrictFilters([
|
$forcedFilters
|
||||||
...($has_permission_other && $user !== false ? [
|
);
|
||||||
'department' => $user->getGroup()->getDepartments()
|
|
||||||
] : []),
|
$payload = [
|
||||||
...(!$has_permission_other && $effectiveCustomer !== null ? [
|
'success' => true,
|
||||||
'customer_number' => [(int)$effectiveCustomer]
|
'data' => $result,
|
||||||
] : [])
|
'meta' => $response->get_meta(),
|
||||||
])
|
'includes' => $response->get_includes(),
|
||||||
));
|
];
|
||||||
|
|
||||||
|
if ($cacheKey !== null) {
|
||||||
|
order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->rawJson($payload);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'list_own_bookings' => 'Permission to list own order bookings.',
|
'list_own_bookings' => 'Permission to list own order bookings.',
|
||||||
@@ -150,6 +176,77 @@ class orderBookingRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->get('/order-bookings/counts', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$auth = new authentication();
|
||||||
|
$user = $auth->get_user();
|
||||||
|
|
||||||
|
$permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST);
|
||||||
|
$permission_other = self::definePermission('list_bookings');
|
||||||
|
$hasPermissionOwn = self::hasPermission($permission_own);
|
||||||
|
$hasPermissionOther = self::hasPermission($permission_other);
|
||||||
|
|
||||||
|
if (!$hasPermissionOwn && !$hasPermissionOther) {
|
||||||
|
$this->emitForbidden([$permission_own, $permission_other]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedDepartmentId = $this->getTargetCountDepartmentId();
|
||||||
|
if ($hasPermissionOther && $requestedDepartmentId !== null) {
|
||||||
|
self::requireDepartmentAccess((string)$requestedDepartmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveCustomer = null;
|
||||||
|
if (!$hasPermissionOther) {
|
||||||
|
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||||
|
if ($effectiveCustomer === null || $effectiveCustomer < 1) {
|
||||||
|
$this->emitForbidden([$permission_other]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$departmentIds = null;
|
||||||
|
if ($requestedDepartmentId !== null) {
|
||||||
|
$departmentIds = [$requestedDepartmentId];
|
||||||
|
} elseif ($hasPermissionOther && $user !== false) {
|
||||||
|
$departmentIds = array_map('intval', $user->getGroup()->getDepartments());
|
||||||
|
if ($departmentIds === []) {
|
||||||
|
$response->success([
|
||||||
|
'past' => 0,
|
||||||
|
'current' => 0,
|
||||||
|
'future' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheTtl = order_bookings_counts_cache::getTtl();
|
||||||
|
$cacheKey = $cacheTtl > 0
|
||||||
|
? $this->getOrderBookingsCountsCacheKey($departmentIds, $effectiveCustomer)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($cacheKey !== null) {
|
||||||
|
$cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);
|
||||||
|
if ($cachedCounts !== null) {
|
||||||
|
$response->success($cachedCounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = (new order_bookings_o())->getPendingBookingCounts(
|
||||||
|
$departmentIds,
|
||||||
|
$effectiveCustomer
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($cacheKey !== null) {
|
||||||
|
order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->success($counts);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'list_own_bookings' => 'Permission to list own order booking counts.',
|
||||||
|
'list_bookings' => 'Permission to list department order booking counts.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->put('/order-bookings', function () {
|
$this->put('/order-bookings', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
@@ -300,6 +397,83 @@ class orderBookingRoute
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$page = (int)($response->getRequestParameter('page') ?? 0);
|
||||||
|
if ($page < 1) {
|
||||||
|
$page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = (int)($response->getRequestParameter('limit') ?? 0);
|
||||||
|
if ($limit < 1) {
|
||||||
|
$limit = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return order_bookings_list_cache::buildKey([
|
||||||
|
'route' => '/order-bookings',
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'search' => (string)($response->getRequestParameter('search') ?? ''),
|
||||||
|
'order' => $this->normalizeOrderBookingsListCacheOrder((string)($response->getRequestParameter('order') ?? 'id:ASC')),
|
||||||
|
'filters' => $object->filter_string_to_array($forcedFilters),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getOrderBookingsCountsCacheKey(?array $departmentIds, ?int $customerNumber): string
|
||||||
|
{
|
||||||
|
return order_bookings_counts_cache::buildKey([
|
||||||
|
'route' => '/order-bookings/counts',
|
||||||
|
'day' => (new \DateTimeImmutable('now'))->format('Y-m-d'),
|
||||||
|
'department_ids' => $departmentIds ?? [],
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeOrderBookingsListCacheOrder(string $order): array
|
||||||
|
{
|
||||||
|
$normalized = trim($order);
|
||||||
|
if ($normalized === '') {
|
||||||
|
$normalized = 'id:ASC';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = array_pad(explode(':', $normalized, 2), 2, 'ASC');
|
||||||
|
$field = trim((string)$parts[0]);
|
||||||
|
$direction = strtoupper(trim((string)$parts[1]));
|
||||||
|
|
||||||
|
if ($field === '') {
|
||||||
|
$field = 'id';
|
||||||
|
}
|
||||||
|
if ($direction === '') {
|
||||||
|
$direction = 'ASC';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$field => $direction];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTargetCountDepartmentId(): ?int
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$parameter = 'department';
|
||||||
|
$rawValue = $this->fromRequest($parameter);
|
||||||
|
if ($rawValue === null || $rawValue === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_numeric($rawValue)) {
|
||||||
|
$response->error('Invalid department', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = (int)$rawValue;
|
||||||
|
if ($value < 1 || $value > 999999999) {
|
||||||
|
$response->error('Invalid department', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -557,6 +557,11 @@ class orderInvoicesRoute
|
|||||||
|
|
||||||
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
|
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
|
||||||
$collected_order_invoices->requireSelected();
|
$collected_order_invoices->requireSelected();
|
||||||
|
try {
|
||||||
|
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
|
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
|
||||||
$response->error('Invoice has already been booked', 400);
|
$response->error('Invoice has already been booked', 400);
|
||||||
@@ -992,6 +997,11 @@ class orderInvoicesRoute
|
|||||||
// Validate the ID against the database
|
// Validate the ID against the database
|
||||||
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
|
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
|
||||||
$collected_order_invoices->requireSelected();
|
$collected_order_invoices->requireSelected();
|
||||||
|
try {
|
||||||
|
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$response->error($e->getMessage(), 400);
|
||||||
|
}
|
||||||
// Require the external ID to be set
|
// Require the external ID to be set
|
||||||
if ($collected_order_invoices->external_id->value() === null) {
|
if ($collected_order_invoices->external_id->value() === null) {
|
||||||
$response->error('Transaction has not been created in Stripe', 400);
|
$response->error('Transaction has not been created in Stripe', 400);
|
||||||
@@ -1782,6 +1792,8 @@ class orderInvoicesRoute
|
|||||||
*/
|
*/
|
||||||
private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array
|
private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array
|
||||||
{
|
{
|
||||||
|
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
|
||||||
|
|
||||||
if ($collected_order_invoices->external_id->value() === null) {
|
if ($collected_order_invoices->external_id->value() === null) {
|
||||||
if (!$send_as_is) {
|
if (!$send_as_is) {
|
||||||
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
|
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
|
||||||
@@ -2023,6 +2035,15 @@ class orderInvoicesRoute
|
|||||||
}, $jobs));
|
}, $jobs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void
|
||||||
|
{
|
||||||
|
$collected_order_invoices->requireSelected();
|
||||||
|
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $collected_order_invoice
|
* @param $collected_order_invoice
|
||||||
* @param users_o $users
|
* @param users_o $users
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use attachments\helpers\attachment_content;
|
|||||||
use classes\attachment_store;
|
use classes\attachment_store;
|
||||||
use classes\attachments;
|
use classes\attachments;
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\economic;
|
||||||
use classes\orders_input_normalizer;
|
use classes\orders_input_normalizer;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\stripe;
|
use classes\stripe;
|
||||||
@@ -986,19 +987,26 @@ class ordersRoute
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
$shouldRefreshAttachedWashCertificate = false;
|
||||||
// PO
|
// PO
|
||||||
if (isset($data['po'])) {
|
if (isset($data['po'])) {
|
||||||
$order->po->set((string)$data['po']);
|
$order->po->set((string)$data['po']);
|
||||||
}
|
}
|
||||||
// Registration numbers
|
// Registration numbers
|
||||||
if (isset($data['reg_1'])) {
|
if (isset($data['reg_1'])) {
|
||||||
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
|
$normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->reg_1->set($normalizedReg1);
|
||||||
}
|
}
|
||||||
if (isset($data['reg_2'])) {
|
if (isset($data['reg_2'])) {
|
||||||
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
|
$normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->reg_2->set($normalizedReg2);
|
||||||
}
|
}
|
||||||
if (isset($data['reg_3'])) {
|
if (isset($data['reg_3'])) {
|
||||||
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
|
$normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->reg_3->set($normalizedReg3);
|
||||||
}
|
}
|
||||||
// Reference
|
// Reference
|
||||||
if (isset($data['reference'])) {
|
if (isset($data['reference'])) {
|
||||||
@@ -1009,7 +1017,12 @@ class ordersRoute
|
|||||||
$order->notes->set((string)$data['notes']);
|
$order->notes->set((string)$data['notes']);
|
||||||
}
|
}
|
||||||
if (array_key_exists('safety_seal', $data)) {
|
if (array_key_exists('safety_seal', $data)) {
|
||||||
$order->setSafetySealValue($data['safety_seal']);
|
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->setSafetySealValue($normalizedSafetySeal);
|
||||||
|
}
|
||||||
|
if ($shouldRefreshAttachedWashCertificate) {
|
||||||
|
$order->regenerateAttachedWashCertificate();
|
||||||
}
|
}
|
||||||
// Register the change
|
// Register the change
|
||||||
$order->objectChanged();
|
$order->objectChanged();
|
||||||
@@ -1019,12 +1032,22 @@ class ordersRoute
|
|||||||
// Admin/department path (requires edit_order)
|
// Admin/department path (requires edit_order)
|
||||||
self::requirePermission($permission_other);
|
self::requirePermission($permission_other);
|
||||||
/** Departmental access */
|
/** Departmental access */
|
||||||
|
$originalCustomerNumber = (int)$order->customer_id->value();
|
||||||
|
$newCustomerNumber = $originalCustomerNumber;
|
||||||
|
$shouldAutoReassignInvoiceCollection = false;
|
||||||
|
$shouldRefreshAttachedWashCertificate = false;
|
||||||
// If the customer ID is set, validate it
|
// If the customer ID is set, validate it
|
||||||
if (isset($data['customer_id'])) {
|
if (isset($data['customer_id'])) {
|
||||||
if (!(new users_o())->getUserByCustomerNumber((int)$data['customer_id'])->exists() || empty($data['customer_id'])) {
|
if (!(new users_o())->getUserByCustomerNumber((int)$data['customer_id'])->exists() || empty($data['customer_id'])) {
|
||||||
$response->error('Customer not found or invalid', 400);
|
$response->error('Customer not found or invalid', 400);
|
||||||
}
|
}
|
||||||
$order->customer_id->set((int)$data['customer_id']);
|
$newCustomerNumber = (int)$data['customer_id'];
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$shouldAutoReassignInvoiceCollection = $this->shouldAutoReassignInvoiceCollectionForDraftTransition(
|
||||||
|
$originalCustomerNumber,
|
||||||
|
$newCustomerNumber
|
||||||
|
);
|
||||||
|
$order->customer_id->set($newCustomerNumber);
|
||||||
}
|
}
|
||||||
// If the reference is set, validate it
|
// If the reference is set, validate it
|
||||||
if (isset($data['reference'])) {
|
if (isset($data['reference'])) {
|
||||||
@@ -1036,22 +1059,30 @@ class ordersRoute
|
|||||||
}
|
}
|
||||||
// If the registration number is set, validate it
|
// If the registration number is set, validate it
|
||||||
if (isset($data['reg_1'])) {
|
if (isset($data['reg_1'])) {
|
||||||
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
|
$normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->reg_1->set($normalizedReg1);
|
||||||
}
|
}
|
||||||
// If the registration number 2 is set, validate it
|
// If the registration number 2 is set, validate it
|
||||||
if (isset($data['reg_2'])) {
|
if (isset($data['reg_2'])) {
|
||||||
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
|
$normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->reg_2->set($normalizedReg2);
|
||||||
}
|
}
|
||||||
// If the registration number 3 is set, validate it
|
// If the registration number 3 is set, validate it
|
||||||
if (isset($data['reg_3'])) {
|
if (isset($data['reg_3'])) {
|
||||||
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
|
$normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->reg_3->set($normalizedReg3);
|
||||||
}
|
}
|
||||||
// If the PO is set, validate it
|
// If the PO is set, validate it
|
||||||
if (isset($data['po'])) {
|
if (isset($data['po'])) {
|
||||||
$order->po->set((string)$data['po']);
|
$order->po->set((string)$data['po']);
|
||||||
}
|
}
|
||||||
if (array_key_exists('safety_seal', $data)) {
|
if (array_key_exists('safety_seal', $data)) {
|
||||||
$order->setSafetySealValue($data['safety_seal']);
|
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']);
|
||||||
|
$shouldRefreshAttachedWashCertificate = true;
|
||||||
|
$order->setSafetySealValue($normalizedSafetySeal);
|
||||||
}
|
}
|
||||||
// If the lane is set, validate it
|
// If the lane is set, validate it
|
||||||
if (isset($data['lane'])) {
|
if (isset($data['lane'])) {
|
||||||
@@ -1069,7 +1100,7 @@ class ordersRoute
|
|||||||
$order->booking_id->set((int)$data['booking_id']);
|
$order->booking_id->set((int)$data['booking_id']);
|
||||||
}
|
}
|
||||||
// Check if the invoice collection is set
|
// Check if the invoice collection is set
|
||||||
if (isset($data['invoice_collection_id'])) {
|
if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) {
|
||||||
$order->invoice_collection_id->set((int)$data['invoice_collection_id']);
|
$order->invoice_collection_id->set((int)$data['invoice_collection_id']);
|
||||||
}
|
}
|
||||||
// Check if the wash_id is set
|
// Check if the wash_id is set
|
||||||
@@ -1094,6 +1125,12 @@ class ordersRoute
|
|||||||
$response->error($e->getMessage(), 400);
|
$response->error($e->getMessage(), 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($shouldAutoReassignInvoiceCollection) {
|
||||||
|
$order->assignToInvoiceCollection(null, false);
|
||||||
|
}
|
||||||
|
if ($shouldRefreshAttachedWashCertificate) {
|
||||||
|
$order->regenerateAttachedWashCertificate();
|
||||||
|
}
|
||||||
// Void any cached key for the order
|
// Void any cached key for the order
|
||||||
$order->objectChanged();
|
$order->objectChanged();
|
||||||
// Log the incident
|
// Log the incident
|
||||||
@@ -1149,6 +1186,17 @@ class ordersRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldAutoReassignInvoiceCollectionForDraftTransition(int $originalCustomerNumber, int $newCustomerNumber): bool
|
||||||
|
{
|
||||||
|
if ($originalCustomerNumber <= 0 || $newCustomerNumber <= 0 || $originalCustomerNumber === $newCustomerNumber) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$economic = new economic();
|
||||||
|
return $economic->isDraftCustomerNumber($originalCustomerNumber)
|
||||||
|
|| $economic->isDraftCustomerNumber($newCustomerNumber);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array<string, mixed>> $orders
|
* @param array<int, array<string, mixed>> $orders
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ it('returns the cached auth session payload for a valid token', function (): voi
|
|||||||
'two_factor_enabled' => false,
|
'two_factor_enabled' => false,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '445566', 'int');
|
||||||
|
|
||||||
$response = api_client()->get('/auth/session', $session['headers']);
|
$response = api_client()->get('/auth/session', $session['headers']);
|
||||||
|
|
||||||
@@ -89,7 +90,31 @@ it('returns the cached auth session payload for a valid token', function (): voi
|
|||||||
->toHaveKey('customer_number', $session['user']['customer_number'])
|
->toHaveKey('customer_number', $session['user']['customer_number'])
|
||||||
->toHaveKey('two_factor_enabled', false)
|
->toHaveKey('two_factor_enabled', false)
|
||||||
->and($response->data()['permissions'])
|
->and($response->data()['permissions'])
|
||||||
->toContain('list_departments');
|
->toContain('list_departments')
|
||||||
|
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
|
||||||
|
->toBe(445566);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes economic runtime config for uncached auth sessions', function (): void {
|
||||||
|
api_test_covers('GET /auth/session', 'happy');
|
||||||
|
|
||||||
|
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '556677', 'int');
|
||||||
|
$session = api_fixtures()->createUserSession(['list_departments'], [
|
||||||
|
'display_name' => 'Fresh Session User',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->get('/auth/session', $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($response->data())
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('customer_number', $session['user']['customer_number'])
|
||||||
|
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
|
||||||
|
->toBe(556677);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid auth session tokens', function (): void {
|
it('rejects invalid auth session tokens', function (): void {
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
const ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
|
||||||
|
|
||||||
|
function economic_draft_customer_find_config_entry(array $entries, string $variable): ?array
|
||||||
|
{
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (is_array($entry) && ($entry['variable'] ?? null) === $variable) {
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lists the draft customer config entry in economic config responses', function (): void {
|
||||||
|
api_test_covers('GET /economic/config', 'happy');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['economic_config']);
|
||||||
|
|
||||||
|
$response = api_client()->get('/economic/config', $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$entry = economic_draft_customer_find_config_entry((array)$response->data(), 'transactionDraftCustomerNumber');
|
||||||
|
|
||||||
|
expect($entry)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($entry['module'] ?? null)->toBe('economic')
|
||||||
|
->and($entry['type'] ?? null)->toBe('int')
|
||||||
|
->and(is_int($entry['value'] ?? null) || ($entry['value'] ?? null) === null)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips the draft customer config value through economic config updates', function (): void {
|
||||||
|
api_test_covers('POST /economic/config', 'happy');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['economic_config']);
|
||||||
|
|
||||||
|
api_client()->post('/economic/config', [
|
||||||
|
'variable' => 'transactionDraftCustomerNumber',
|
||||||
|
'value' => 667788,
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$configuredResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']);
|
||||||
|
$configuredResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$configuredEntry = economic_draft_customer_find_config_entry((array)$configuredResponse->data(), 'transactionDraftCustomerNumber');
|
||||||
|
expect($configuredEntry)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($configuredEntry['value'] ?? null)->toBe(667788);
|
||||||
|
|
||||||
|
api_client()->post('/economic/config', [
|
||||||
|
'variable' => 'transactionDraftCustomerNumber',
|
||||||
|
'value' => null,
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$clearedResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']);
|
||||||
|
$clearedResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$clearedEntry = economic_draft_customer_find_config_entry((array)$clearedResponse->data(), 'transactionDraftCustomerNumber');
|
||||||
|
expect($clearedEntry)->toBeArray();
|
||||||
|
expect(array_key_exists('value', $clearedEntry))->toBeTrue();
|
||||||
|
expect($clearedEntry['value'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects order draft exports for the configured draft customer', function (): void {
|
||||||
|
api_test_covers('POST /economic/invoice/draft/export', 'failure');
|
||||||
|
|
||||||
|
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Export Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Draft Export Cashier']);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $draftCustomer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'BLOCK-DRAFT',
|
||||||
|
'reg_1' => 'DRFT123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
|
||||||
|
$session = api_fixtures()->createUserSession(['economic_invoice_draft_export']);
|
||||||
|
|
||||||
|
api_client()->post('/economic/invoice/draft/export', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects booked invoice exports for the configured draft customer', function (): void {
|
||||||
|
api_test_covers('POST /economic/invoice/export', 'failure');
|
||||||
|
|
||||||
|
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Booked Export Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Booked Export Cashier']);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $draftCustomer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'BLOCK-INVOICE',
|
||||||
|
'reg_1' => 'INV1234',
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
|
||||||
|
$session = api_fixtures()->createUserSession(['economic_invoice_export']);
|
||||||
|
|
||||||
|
api_client()->post('/economic/invoice/export', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects collected invoice exports for the configured draft customer', function (): void {
|
||||||
|
api_test_covers('POST /collected-invoices/economic', 'failure');
|
||||||
|
|
||||||
|
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Collected Export Customer']);
|
||||||
|
$invoiceCollection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $draftCustomer['customer_number'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
|
||||||
|
$session = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
|
||||||
|
|
||||||
|
api_client()->post('/collected-invoices/economic', [
|
||||||
|
'id' => $invoiceCollection['id'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
|
||||||
|
});
|
||||||
@@ -4,6 +4,39 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
usesApiSuite();
|
usesApiSuite();
|
||||||
|
|
||||||
|
function activeWashCertificateAttachmentIdsForOrder(int $orderId): array
|
||||||
|
{
|
||||||
|
$statement = api_test_runtime()->db()->prepare(
|
||||||
|
'SELECT id, content
|
||||||
|
FROM object_attachments
|
||||||
|
WHERE object_type IN (?, ?)
|
||||||
|
AND object_id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY id ASC'
|
||||||
|
);
|
||||||
|
expect($statement)->not->toBeFalse();
|
||||||
|
|
||||||
|
$objectType = 'orders';
|
||||||
|
$backtickedObjectType = '`orders`';
|
||||||
|
$statement->bind_param('ssi', $objectType, $backtickedObjectType, $orderId);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$result = $statement->get_result();
|
||||||
|
$attachmentIds = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$content = json_decode((string)($row['content'] ?? ''), true);
|
||||||
|
$other = is_array($content) ? ($content['other'] ?? null) : null;
|
||||||
|
if (is_string($other) && strtolower($other) === 'wash_certificate') {
|
||||||
|
$attachmentIds[] = (int)($row['id'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result->free();
|
||||||
|
$statement->close();
|
||||||
|
|
||||||
|
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
|
||||||
|
}
|
||||||
|
|
||||||
it('lists orders for an admin-scoped user and limits the results to the permitted departments', function (): void {
|
it('lists orders for an admin-scoped user and limits the results to the permitted departments', function (): void {
|
||||||
api_test_covers('GET /orders', 'happy');
|
api_test_covers('GET /orders', 'happy');
|
||||||
|
|
||||||
@@ -297,6 +330,190 @@ it('updates orders through the primary endpoint', function (): void {
|
|||||||
expect((int)($row['include_in_invoice'] ?? 1))->toBe(0);
|
expect((int)($row['include_in_invoice'] ?? 1))->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reassigns invoice collections when changing an order across the draft customer boundary', function (): void {
|
||||||
|
api_test_covers('PUT /orders', 'happy');
|
||||||
|
|
||||||
|
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Customer']);
|
||||||
|
$regularCustomer = api_fixtures()->createUser(['display_name' => 'Regular Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Draft Boundary Department']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Draft Boundary Cashier']);
|
||||||
|
|
||||||
|
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
|
||||||
|
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $regularCustomer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'DRAFT-BOUNDARY',
|
||||||
|
'reg_1' => 'DRAFT123',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession(['edit_order']);
|
||||||
|
|
||||||
|
api_client()->put('/orders', [
|
||||||
|
'id' => $order['id'],
|
||||||
|
'customer_id' => $draftCustomer['customer_number'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess()
|
||||||
|
->assertMessage('Order updated successfully');
|
||||||
|
|
||||||
|
$draftRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
|
||||||
|
expect($draftRow)->not->toBeNull();
|
||||||
|
$draftInvoiceCollectionId = (int)($draftRow['invoice_collection_id'] ?? 0);
|
||||||
|
expect($draftInvoiceCollectionId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$draftCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $draftInvoiceCollectionId);
|
||||||
|
expect($draftCollectionRow)->not->toBeNull();
|
||||||
|
expect((int)($draftCollectionRow['customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']);
|
||||||
|
|
||||||
|
api_client()->put('/orders', [
|
||||||
|
'id' => $order['id'],
|
||||||
|
'customer_id' => $regularCustomer['customer_number'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess()
|
||||||
|
->assertMessage('Order updated successfully');
|
||||||
|
|
||||||
|
$regularRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
|
||||||
|
expect($regularRow)->not->toBeNull();
|
||||||
|
$regularInvoiceCollectionId = (int)($regularRow['invoice_collection_id'] ?? 0);
|
||||||
|
expect($regularInvoiceCollectionId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$regularCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $regularInvoiceCollectionId);
|
||||||
|
expect($regularCollectionRow)->not->toBeNull();
|
||||||
|
expect((int)($regularCollectionRow['customer_number'] ?? 0))->toBe((int)$regularCustomer['customer_number']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerates attached wash certificates when certificate metadata changes through the primary endpoint', function (): void {
|
||||||
|
api_test_covers('PUT /orders', 'happy');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Department']);
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Original Certificate Customer']);
|
||||||
|
$updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Certificate Customer']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Certificate Cashier']);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'CERT-REF',
|
||||||
|
'reg_1' => 'CERT123',
|
||||||
|
'reg_2' => 'TRAIL1',
|
||||||
|
'safety_seal' => 'SEAL-OLD',
|
||||||
|
'created_at' => '2026-04-12 10:15:00',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'edit_order',
|
||||||
|
'add_order_attachments',
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_client()->post('/orders/attachments/upload', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'base64_file' => base64_encode('wash-certificate'),
|
||||||
|
'file_name' => 'wash_certificate',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
|
||||||
|
expect($initialAttachmentIds)->toHaveCount(1);
|
||||||
|
|
||||||
|
$initialAttachmentId = $initialAttachmentIds[0];
|
||||||
|
$initialAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
|
||||||
|
expect($initialAttachmentRow)->not->toBeNull();
|
||||||
|
$initialDocument = (json_decode((string)($initialAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null;
|
||||||
|
|
||||||
|
api_client()->put('/orders', [
|
||||||
|
'id' => $order['id'],
|
||||||
|
'customer_id' => $updatedCustomer['customer_number'],
|
||||||
|
'reg_2' => ' new-trail-55 ',
|
||||||
|
'safety_seal' => 'SEAL-NEW',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess()
|
||||||
|
->assertMessage('Order updated successfully');
|
||||||
|
|
||||||
|
$orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
|
||||||
|
expect($orderRow)->not->toBeNull();
|
||||||
|
expect((int)($orderRow['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']);
|
||||||
|
expect($orderRow['reg_2'] ?? null)->toBe('NEWTRAIL55');
|
||||||
|
expect($orderRow['safety_seal'] ?? null)->toBe('SEAL-NEW');
|
||||||
|
|
||||||
|
$updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
|
||||||
|
expect($updatedAttachmentIds)->toHaveCount(1);
|
||||||
|
expect($updatedAttachmentIds)->not->toContain($initialAttachmentId);
|
||||||
|
|
||||||
|
$updatedAttachmentId = $updatedAttachmentIds[0];
|
||||||
|
$updatedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $updatedAttachmentId);
|
||||||
|
$deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
|
||||||
|
expect($updatedAttachmentRow)->not->toBeNull();
|
||||||
|
expect($deletedAttachmentRow)->not->toBeNull();
|
||||||
|
expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull();
|
||||||
|
expect((json_decode((string)($updatedAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null)->not->toBe($initialDocument);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerates attached wash certificates for legacy field-value updates through the alias endpoint', function (): void {
|
||||||
|
api_test_covers('PUT /order', 'happy');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Alias Wash Certificate Department']);
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Alias Certificate Customer']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Alias Certificate Cashier']);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ALIAS-CERT',
|
||||||
|
'reg_1' => 'ALIAS123',
|
||||||
|
'safety_seal' => 'ALIAS-SEAL',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'edit_order',
|
||||||
|
'add_order_attachments',
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_client()->post('/orders/attachments/upload', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'base64_file' => base64_encode('wash-certificate'),
|
||||||
|
'file_name' => 'wash_certificate',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
|
||||||
|
expect($initialAttachmentIds)->toHaveCount(1);
|
||||||
|
|
||||||
|
$initialAttachmentId = $initialAttachmentIds[0];
|
||||||
|
|
||||||
|
api_client()->put('/order', [
|
||||||
|
'id' => $order['id'],
|
||||||
|
'field' => 'reg_1',
|
||||||
|
'value' => ' zz-88 11 ',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess()
|
||||||
|
->assertMessage('Order updated successfully');
|
||||||
|
|
||||||
|
$orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
|
||||||
|
expect($orderRow)->not->toBeNull();
|
||||||
|
expect($orderRow['reg_1'] ?? null)->toBe('ZZ8811');
|
||||||
|
|
||||||
|
$updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
|
||||||
|
expect($updatedAttachmentIds)->toHaveCount(1);
|
||||||
|
expect($updatedAttachmentIds)->not->toContain($initialAttachmentId);
|
||||||
|
|
||||||
|
$deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
|
||||||
|
|
||||||
|
expect($deletedAttachmentRow)->not->toBeNull();
|
||||||
|
expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('supports legacy field-value metadata updates through the primary endpoint', function (): void {
|
it('supports legacy field-value metadata updates through the primary endpoint', function (): void {
|
||||||
api_test_covers('PUT /orders', 'happy');
|
api_test_covers('PUT /orders', 'happy');
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,26 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use classes\email;
|
||||||
|
use classes\stripe;
|
||||||
|
use classes\stripe_fake_http_client;
|
||||||
|
|
||||||
|
putenv('STRIPE_FAKE_MODE=1');
|
||||||
|
putenv('STRIPE_FAKE_STORE_PATH=' . sys_get_temp_dir() . '/truckwash-stripe-api-tests.json');
|
||||||
|
putenv('EMAIL_FAKE_MODE=1');
|
||||||
|
|
||||||
usesApiSuite();
|
usesApiSuite();
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
stripe_fake_http_client::resetStore();
|
||||||
|
email::resetFakeDeliveries();
|
||||||
|
|
||||||
|
if (api_tests_enabled()) {
|
||||||
|
api_test_runtime()->db()->query("DELETE FROM stripe_module_orders WHERE invoice_id LIKE 'in_fake_%'");
|
||||||
|
api_test_runtime()->db()->query("DELETE FROM stripe_module_customers WHERE customer_id LIKE 'cus_fake_%' OR email LIKE 'stripe-%@example.com'");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a setup required error when department terminal readers are requested without terminal setup', function (): void {
|
it('returns a setup required error when department terminal readers are requested without terminal setup', function (): void {
|
||||||
$department = api_fixtures()->createDepartment([
|
$department = api_fixtures()->createDepartment([
|
||||||
'name' => 'Stripe Setup Pending Department',
|
'name' => 'Stripe Setup Pending Department',
|
||||||
@@ -29,6 +47,248 @@ it('returns a setup required error when department terminal readers are requeste
|
|||||||
->toHaveKey('code', 'stripe_terminal_setup_required');
|
->toHaveKey('code', 'stripe_terminal_setup_required');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends a Stripe invoice by email and persists the hosted invoice association', function (): void {
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Stripe Email Payments Department',
|
||||||
|
]);
|
||||||
|
$customer = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Stripe Hosted Invoice Customer',
|
||||||
|
]);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'STRIPE-EMAIL-SEND',
|
||||||
|
'reg_1' => 'EMAIL01',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'modules_stripe_invoice_send',
|
||||||
|
]);
|
||||||
|
$emailAddress = sprintf('stripe-email-%d@example.com', (int)$order['id']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/stripe/invoice', [
|
||||||
|
'email' => $emailAddress,
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(true);
|
||||||
|
|
||||||
|
expect($response->data())
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('id')
|
||||||
|
->toHaveKey('hosted_invoice_url')
|
||||||
|
->toHaveKey('metadata');
|
||||||
|
|
||||||
|
expect($response->data()['metadata'] ?? [])
|
||||||
|
->toMatchArray([
|
||||||
|
'order_id' => (string)$order['id'],
|
||||||
|
'customer_id' => (string)$customer['customer_number'],
|
||||||
|
'department_id' => (string)$department['id'],
|
||||||
|
'reference' => 'STRIPE-EMAIL-SEND',
|
||||||
|
'reg_1' => 'EMAIL01',
|
||||||
|
])
|
||||||
|
->and(($response->data()['metadata']['stripe_customer_id'] ?? null))
|
||||||
|
->toBe((string)($response->data()['customer'] ?? ''));
|
||||||
|
|
||||||
|
$retrievedInvoice = (new stripe())->invoice->retrieve((string)$response->data()['id']);
|
||||||
|
$retrievedMetadata = json_decode(json_encode($retrievedInvoice->metadata), true);
|
||||||
|
if (!is_array($retrievedMetadata)) {
|
||||||
|
$retrievedMetadata = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($retrievedMetadata)
|
||||||
|
->toMatchArray([
|
||||||
|
'order_id' => (string)$order['id'],
|
||||||
|
'customer_id' => (string)$customer['customer_number'],
|
||||||
|
'department_id' => (string)$department['id'],
|
||||||
|
'reference' => 'STRIPE-EMAIL-SEND',
|
||||||
|
'reg_1' => 'EMAIL01',
|
||||||
|
])
|
||||||
|
->and(($retrievedMetadata['stripe_customer_id'] ?? null))
|
||||||
|
->toBe((string)($response->data()['customer'] ?? ''));
|
||||||
|
|
||||||
|
$stored = api_test_runtime()->queryOne(
|
||||||
|
'SELECT invoice_id, customer_id, url FROM stripe_module_orders WHERE id = ' . (int)$order['id']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($stored)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($stored['invoice_id'] ?? null)->toBe((string)$response->data()['id'])
|
||||||
|
->and($stored['customer_id'] ?? null)->not->toBe('')
|
||||||
|
->and($stored['url'] ?? null)->toBe((string)$response->data()['hosted_invoice_url']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a conflict when a Stripe hosted invoice is already active for the order', function (): void {
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Stripe Email Guard Department',
|
||||||
|
]);
|
||||||
|
$customer = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Stripe Duplicate Guard Customer',
|
||||||
|
]);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'STRIPE-EMAIL-GUARD',
|
||||||
|
'reg_1' => 'EMAIL02',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'modules_stripe_invoice_send',
|
||||||
|
]);
|
||||||
|
$emailAddress = sprintf('stripe-duplicate-%d@example.com', (int)$order['id']);
|
||||||
|
|
||||||
|
api_client()->post('/modules/stripe/invoice', [
|
||||||
|
'email' => $emailAddress,
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers'])->assertStatus(200);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/stripe/invoice', [
|
||||||
|
'email' => $emailAddress,
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(409)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('A Stripe payment link is already active for this order.');
|
||||||
|
|
||||||
|
expect($response->data())
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('code', 'stripe_invoice_exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows sending a new Stripe hosted invoice when the existing association is already terminal', function (): void {
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Stripe Email Terminal Guard Department',
|
||||||
|
]);
|
||||||
|
$customer = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Stripe Terminal Guard Customer',
|
||||||
|
]);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'STRIPE-EMAIL-TERMINAL',
|
||||||
|
'reg_1' => 'EMAIL05',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'modules_stripe_invoice_send',
|
||||||
|
]);
|
||||||
|
$emailAddress = sprintf('stripe-terminal-%d@example.com', (int)$order['id']);
|
||||||
|
|
||||||
|
$firstResponse = api_client()->post('/modules/stripe/invoice', [
|
||||||
|
'email' => $emailAddress,
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$firstInvoiceId = (string)($firstResponse->data()['id'] ?? '');
|
||||||
|
stripe_fake_http_client::setInvoiceState($firstInvoiceId, [
|
||||||
|
'status' => 'void',
|
||||||
|
'paid' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$secondResponse = api_client()->post('/modules/stripe/invoice', [
|
||||||
|
'email' => $emailAddress,
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$secondResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(true);
|
||||||
|
|
||||||
|
expect((string)($secondResponse->data()['id'] ?? ''))
|
||||||
|
->not->toBe('')
|
||||||
|
->not->toBe($firstInvoiceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('voids an unpaid Stripe hosted invoice and clears the local order association', function (): void {
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Stripe Email Cancel Department',
|
||||||
|
]);
|
||||||
|
$customer = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Stripe Cancel Customer',
|
||||||
|
]);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'STRIPE-EMAIL-CANCEL',
|
||||||
|
'reg_1' => 'EMAIL03',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'modules_stripe_invoice_send',
|
||||||
|
]);
|
||||||
|
$emailAddress = sprintf('stripe-cancel-%d@example.com', (int)$order['id']);
|
||||||
|
|
||||||
|
$sendResponse = api_client()->post('/modules/stripe/invoice', [
|
||||||
|
'email' => $emailAddress,
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
$invoiceId = (string)($sendResponse->data()['id'] ?? '');
|
||||||
|
|
||||||
|
$response = api_client()->delete('/modules/stripe/invoice', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(true);
|
||||||
|
|
||||||
|
expect(api_test_runtime()->queryOne(
|
||||||
|
'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id']
|
||||||
|
))->toBeNull();
|
||||||
|
|
||||||
|
expect((new stripe())->invoice->retrieve($invoiceId)->status)->toBe('void');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to cancel a paid Stripe hosted invoice', function (): void {
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Stripe Email Paid Department',
|
||||||
|
]);
|
||||||
|
$customer = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Stripe Paid Customer',
|
||||||
|
]);
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'STRIPE-EMAIL-PAID',
|
||||||
|
'reg_1' => 'EMAIL04',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'modules_stripe_invoice_send',
|
||||||
|
]);
|
||||||
|
$emailAddress = sprintf('stripe-paid-%d@example.com', (int)$order['id']);
|
||||||
|
|
||||||
|
$sendResponse = api_client()->post('/modules/stripe/invoice', [
|
||||||
|
'email' => $emailAddress,
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
$invoiceId = (string)($sendResponse->data()['id'] ?? '');
|
||||||
|
|
||||||
|
stripe_fake_http_client::setInvoiceState($invoiceId, [
|
||||||
|
'status' => 'paid',
|
||||||
|
'paid' => true,
|
||||||
|
'amount_due' => 0,
|
||||||
|
'amount_paid' => 1000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->delete('/modules/stripe/invoice', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(409)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('A paid Stripe payment link cannot be cancelled.');
|
||||||
|
|
||||||
|
expect(api_test_runtime()->queryOne(
|
||||||
|
'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id']
|
||||||
|
))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void {
|
it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void {
|
||||||
$department = api_fixtures()->createDepartment([
|
$department = api_fixtures()->createDepartment([
|
||||||
'name' => 'Stripe Payment Intent Pending Department',
|
'name' => 'Stripe Payment Intent Pending Department',
|
||||||
|
|||||||
@@ -18,6 +18,60 @@ function app_require(string $relative): void
|
|||||||
require_once app_path($relative);
|
require_once app_path($relative);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
$class = ltrim($class, '\\');
|
||||||
|
if ($class === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('\\', $class);
|
||||||
|
$top = strtolower($parts[0] ?? '');
|
||||||
|
$relative = implode(DIRECTORY_SEPARATOR, array_slice($parts, 1));
|
||||||
|
|
||||||
|
if ($relative === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = app_path();
|
||||||
|
$candidates = [];
|
||||||
|
|
||||||
|
if (in_array($top, ['classes', 'interfaces', 'traits', 'objects', 'routes', 'statistics'], true)) {
|
||||||
|
$candidates[] = $base . DIRECTORY_SEPARATOR . $top . DIRECTORY_SEPARATOR . $relative;
|
||||||
|
} elseif ($top === 'modules') {
|
||||||
|
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $relative;
|
||||||
|
} else {
|
||||||
|
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||||
|
$modules_root = $base . DIRECTORY_SEPARATOR . 'modules';
|
||||||
|
if (is_dir($modules_root)) {
|
||||||
|
$module_dirs = array_filter(scandir($modules_root) ?: [], static function (string $entry) use ($modules_root): bool {
|
||||||
|
return $entry !== '.' && $entry !== '..' && is_dir($modules_root . DIRECTORY_SEPARATOR . $entry);
|
||||||
|
});
|
||||||
|
foreach ($module_dirs as $module_dir) {
|
||||||
|
$candidates[] = $modules_root . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidates as $path) {
|
||||||
|
foreach (['', '_t', '_o', '_s', '_i', '_c', '_m'] as $suffix) {
|
||||||
|
$file = $path . $suffix . '.php';
|
||||||
|
if (!is_file($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $file;
|
||||||
|
if (
|
||||||
|
class_exists($class, false)
|
||||||
|
|| interface_exists($class, false)
|
||||||
|
|| trait_exists($class, false)
|
||||||
|
|| (function_exists('enum_exists') && enum_exists($class, false))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function integration_enabled(): bool
|
function integration_enabled(): bool
|
||||||
{
|
{
|
||||||
return getenv('RUN_INTEGRATION_TESTS') === '1';
|
return getenv('RUN_INTEGRATION_TESTS') === '1';
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/order_bookings_counts_cache.php');
|
||||||
|
|
||||||
|
use classes\order_bookings_counts_cache;
|
||||||
|
|
||||||
|
if (!class_exists('OrderBookingsCountsCacheRedisFake')) {
|
||||||
|
class OrderBookingsCountsCacheRedisFake
|
||||||
|
{
|
||||||
|
/** @var array<string,string> */
|
||||||
|
public array $store = [];
|
||||||
|
|
||||||
|
public function get(string $key): ?string
|
||||||
|
{
|
||||||
|
return $this->store[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEx(string $key, string $value, int $ttl): void
|
||||||
|
{
|
||||||
|
$this->store[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear_keys(string $pattern): void
|
||||||
|
{
|
||||||
|
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
|
||||||
|
foreach (array_keys($this->store) as $key) {
|
||||||
|
if (preg_match($regex, $key) === 1) {
|
||||||
|
unset($this->store[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->oldTtl = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
|
||||||
|
$this->redis = new OrderBookingsCountsCacheRedisFake();
|
||||||
|
order_bookings_counts_cache::setAdapterForTests($this->redis);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
order_bookings_counts_cache::setAdapterForTests(null);
|
||||||
|
|
||||||
|
if ($this->oldTtl === false) {
|
||||||
|
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=' . $this->oldTtl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
|
||||||
|
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
|
||||||
|
expect(order_bookings_counts_cache::getTtl())->toBe(30);
|
||||||
|
|
||||||
|
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=20');
|
||||||
|
expect(order_bookings_counts_cache::getTtl())->toBe(20);
|
||||||
|
|
||||||
|
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=-1');
|
||||||
|
expect(order_bookings_counts_cache::getTtl())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds deterministic keys for equivalent count contexts', function (): void {
|
||||||
|
$keyA = order_bookings_counts_cache::buildKey([
|
||||||
|
'route' => '/order-bookings/counts',
|
||||||
|
'day' => '2026-04-21',
|
||||||
|
'department_ids' => ['3', '1'],
|
||||||
|
'customer_number' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$keyB = order_bookings_counts_cache::buildKey([
|
||||||
|
'customer_number' => null,
|
||||||
|
'route' => '/order-bookings/counts',
|
||||||
|
'department_ids' => [1, 3],
|
||||||
|
'day' => '2026-04-21',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($keyA)->toBe($keyB);
|
||||||
|
expect($keyA)->toStartWith(order_bookings_counts_cache::PREFIX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and retrieves normalized booking counts', function (): void {
|
||||||
|
$key = order_bookings_counts_cache::buildKey([
|
||||||
|
'route' => '/order-bookings/counts',
|
||||||
|
'day' => '2026-04-21',
|
||||||
|
'department_ids' => [12],
|
||||||
|
]);
|
||||||
|
|
||||||
|
order_bookings_counts_cache::storeCounts($key, [
|
||||||
|
'past' => '2',
|
||||||
|
'current' => 3,
|
||||||
|
'future' => 1,
|
||||||
|
], 60);
|
||||||
|
|
||||||
|
expect(order_bookings_counts_cache::getCounts($key))->toEqual([
|
||||||
|
'past' => 2,
|
||||||
|
'current' => 3,
|
||||||
|
'future' => 1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores malformed payloads and clears order-bookings count caches', function (): void {
|
||||||
|
$validKey = order_bookings_counts_cache::buildKey([
|
||||||
|
'route' => '/order-bookings/counts',
|
||||||
|
'day' => '2026-04-21',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->redis->store[$validKey] = '{"past":1,"current":2,"future":3}';
|
||||||
|
$this->redis->store[order_bookings_counts_cache::PREFIX . 'broken'] = '{"count":5}';
|
||||||
|
$this->redis->store['other:key'] = '{"keep":true}';
|
||||||
|
|
||||||
|
expect(order_bookings_counts_cache::getCounts(order_bookings_counts_cache::PREFIX . 'broken'))->toBeNull();
|
||||||
|
|
||||||
|
order_bookings_counts_cache::clearAll();
|
||||||
|
|
||||||
|
expect($this->redis->store)->toHaveKey('other:key');
|
||||||
|
expect($this->redis->store)->not->toHaveKey($validKey);
|
||||||
|
expect($this->redis->store)->not->toHaveKey(order_bookings_counts_cache::PREFIX . 'broken');
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('adds a cached order-bookings counts endpoint and invalidates it on booking changes', function (): void {
|
||||||
|
$routeFile = app_path('routes/orderBookingRoute.php');
|
||||||
|
$objectFile = app_path('objects/order_bookings_o.php');
|
||||||
|
|
||||||
|
expect(is_file($routeFile))->toBeTrue();
|
||||||
|
expect(is_file($objectFile))->toBeTrue();
|
||||||
|
|
||||||
|
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
|
||||||
|
$objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile));
|
||||||
|
|
||||||
|
expect($routeCode)->toContain('use classes\order_bookings_counts_cache;');
|
||||||
|
expect($routeCode)->toContain("\$this->get('/order-bookings/counts', function () {");
|
||||||
|
expect($routeCode)->toContain('$cacheTtl = order_bookings_counts_cache::getTtl();');
|
||||||
|
expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsCountsCacheKey(');
|
||||||
|
expect($routeCode)->toContain('$cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);');
|
||||||
|
expect($routeCode)->toContain('order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);');
|
||||||
|
expect($routeCode)->toContain('getPendingBookingCounts(');
|
||||||
|
|
||||||
|
expect($objectCode)->toContain('use classes\order_bookings_counts_cache;');
|
||||||
|
expect($objectCode)->toContain('order_bookings_counts_cache::clearAll();');
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/order_bookings_list_cache.php');
|
||||||
|
|
||||||
|
use classes\order_bookings_list_cache;
|
||||||
|
|
||||||
|
if (!class_exists('OrderBookingsListCacheRedisFake')) {
|
||||||
|
class OrderBookingsListCacheRedisFake
|
||||||
|
{
|
||||||
|
/** @var array<string,string> */
|
||||||
|
public array $store = [];
|
||||||
|
|
||||||
|
public function reset(): void
|
||||||
|
{
|
||||||
|
$this->store = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key): ?string
|
||||||
|
{
|
||||||
|
return $this->store[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEx(string $key, string $value, int $ttl): void
|
||||||
|
{
|
||||||
|
$this->store[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear_keys(string $pattern): void
|
||||||
|
{
|
||||||
|
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
|
||||||
|
foreach (array_keys($this->store) as $key) {
|
||||||
|
if (preg_match($regex, $key) === 1) {
|
||||||
|
unset($this->store[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->oldTtl = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
|
||||||
|
$this->redis = new OrderBookingsListCacheRedisFake();
|
||||||
|
order_bookings_list_cache::setAdapterForTests($this->redis);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
order_bookings_list_cache::setAdapterForTests(null);
|
||||||
|
|
||||||
|
if ($this->oldTtl === false) {
|
||||||
|
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=' . $this->oldTtl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
|
||||||
|
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
|
||||||
|
expect(order_bookings_list_cache::getTtl())->toBe(30);
|
||||||
|
|
||||||
|
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=45');
|
||||||
|
expect(order_bookings_list_cache::getTtl())->toBe(45);
|
||||||
|
|
||||||
|
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=-10');
|
||||||
|
expect(order_bookings_list_cache::getTtl())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds deterministic keys for equivalent contexts', function (): void {
|
||||||
|
$keyA = order_bookings_list_cache::buildKey([
|
||||||
|
'route' => '/order-bookings',
|
||||||
|
'page' => 1,
|
||||||
|
'limit' => 100,
|
||||||
|
'search' => '',
|
||||||
|
'order' => ['datetime' => 'DESC'],
|
||||||
|
'filters' => [
|
||||||
|
'customer_number' => '42',
|
||||||
|
'department' => ['3', '1'],
|
||||||
|
'order_id' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$keyB = order_bookings_list_cache::buildKey([
|
||||||
|
'search' => '',
|
||||||
|
'route' => '/order-bookings',
|
||||||
|
'limit' => 100,
|
||||||
|
'filters' => [
|
||||||
|
'order_id' => null,
|
||||||
|
'department' => [1, 3],
|
||||||
|
'customer_number' => 42,
|
||||||
|
],
|
||||||
|
'order' => ['datetime' => 'DESC'],
|
||||||
|
'page' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($keyA)->toBe($keyB);
|
||||||
|
expect($keyA)->toStartWith(order_bookings_list_cache::PREFIX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and retrieves full order-bookings payloads', function (): void {
|
||||||
|
$key = order_bookings_list_cache::buildKey([
|
||||||
|
'route' => '/order-bookings',
|
||||||
|
'page' => 1,
|
||||||
|
'limit' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
['id' => 123],
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'pagination' => [
|
||||||
|
'page' => 1,
|
||||||
|
'per_page' => 100,
|
||||||
|
'total' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'includes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
order_bookings_list_cache::storePayload($key, $payload, 60);
|
||||||
|
|
||||||
|
expect(order_bookings_list_cache::getPayload($key))->toBe($payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores malformed cached payloads and clears order-bookings list caches', function (): void {
|
||||||
|
$validKey = order_bookings_list_cache::buildKey([
|
||||||
|
'route' => '/order-bookings',
|
||||||
|
'page' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->redis->store[$validKey] = '{"success":true,"data":[]}';
|
||||||
|
$this->redis->store[order_bookings_list_cache::PREFIX . 'broken'] = '{"not":"a payload"}';
|
||||||
|
$this->redis->store['other:cache:key'] = '{"leave":"me"}';
|
||||||
|
|
||||||
|
expect(order_bookings_list_cache::getPayload(order_bookings_list_cache::PREFIX . 'broken'))->toBeNull();
|
||||||
|
|
||||||
|
order_bookings_list_cache::clearAll();
|
||||||
|
|
||||||
|
expect($this->redis->store)->toHaveKey('other:cache:key');
|
||||||
|
expect($this->redis->store)->not->toHaveKey($validKey);
|
||||||
|
expect($this->redis->store)->not->toHaveKey(order_bookings_list_cache::PREFIX . 'broken');
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('caches the paginated order-bookings list response and invalidates it on booking changes', function (): void {
|
||||||
|
$routeFile = app_path('routes/orderBookingRoute.php');
|
||||||
|
$objectFile = app_path('objects/order_bookings_o.php');
|
||||||
|
|
||||||
|
expect(is_file($routeFile))->toBeTrue();
|
||||||
|
expect(is_file($objectFile))->toBeTrue();
|
||||||
|
|
||||||
|
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
|
||||||
|
$objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile));
|
||||||
|
|
||||||
|
expect($routeCode)->toContain('use classes\order_bookings_list_cache;');
|
||||||
|
expect($routeCode)->toContain('$cacheTtl = order_bookings_list_cache::getTtl();');
|
||||||
|
expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;');
|
||||||
|
expect($routeCode)->toContain('$cachedPayload = order_bookings_list_cache::getPayload($cacheKey);');
|
||||||
|
expect($routeCode)->toContain('$response->rawJson($cachedPayload);');
|
||||||
|
expect($routeCode)->toContain('order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);');
|
||||||
|
expect($routeCode)->toContain('$response->rawJson($payload);');
|
||||||
|
expect($routeCode)->toContain('private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string');
|
||||||
|
|
||||||
|
expect($objectCode)->toContain('use classes\order_bookings_list_cache;');
|
||||||
|
expect($objectCode)->toContain('order_bookings_list_cache::clearAll();');
|
||||||
|
});
|
||||||
+35
-4
@@ -217,8 +217,8 @@ it('builds the overview payload from batched repository data with deterministic
|
|||||||
(object)[
|
(object)[
|
||||||
'departmentID' => 'dep_1',
|
'departmentID' => 'dep_1',
|
||||||
'start' => '2026-03-23T09:00:00+00:00',
|
'start' => '2026-03-23T09:00:00+00:00',
|
||||||
'end' => '2026-03-23T17:00:00+00:00',
|
'end' => '2026-03-23T17:30:00+00:00',
|
||||||
'approval' => (object)['originalEnd' => '2026-03-23T17:30:00+00:00'],
|
'approval' => (object)['originalEnd' => '2026-03-23T17:00:00+00:00'],
|
||||||
],
|
],
|
||||||
(object)[
|
(object)[
|
||||||
'departmentID' => 'dep_2',
|
'departmentID' => 'dep_2',
|
||||||
@@ -299,13 +299,44 @@ it('limits overtime counting to the selected reporting range', function (): void
|
|||||||
|
|
||||||
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
|
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
|
||||||
'start' => '2026-03-22T20:00:00+00:00',
|
'start' => '2026-03-22T20:00:00+00:00',
|
||||||
'end' => '2026-03-22T23:45:00+00:00',
|
'end' => '2026-03-23T00:30:00+00:00',
|
||||||
'approval' => (object)['originalEnd' => '2026-03-23T00:30:00+00:00'],
|
'approval' => (object)['originalEnd' => '2026-03-22T23:45:00+00:00'],
|
||||||
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
|
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
|
||||||
|
|
||||||
expect($hours)->toBe(0.5);
|
expect($hours)->toBe(0.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not count negative approved overtime when a saved end shortens the shift', function (): void {
|
||||||
|
$route = new DepartmentDailyReportsOverviewRouteDouble();
|
||||||
|
$route->repository = new FakeDailyReportRepository();
|
||||||
|
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
|
||||||
|
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
|
||||||
|
|
||||||
|
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
|
||||||
|
'start' => '2026-03-23T10:00:00+00:00',
|
||||||
|
'end' => '2026-03-23T17:00:00+00:00',
|
||||||
|
'approval' => (object)['originalEnd' => '2026-03-23T18:00:00+00:00'],
|
||||||
|
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
|
||||||
|
|
||||||
|
expect($hours)->toBe(0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores late unapproved administrative edits when calculating overtime', function (): void {
|
||||||
|
$route = new DepartmentDailyReportsOverviewRouteDouble();
|
||||||
|
$route->repository = new FakeDailyReportRepository();
|
||||||
|
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
|
||||||
|
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
|
||||||
|
|
||||||
|
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
|
||||||
|
'start' => '2026-03-23T09:00:00+00:00',
|
||||||
|
'end' => '2026-03-23T17:00:00+00:00',
|
||||||
|
'approval' => null,
|
||||||
|
'updateTime' => '2026-03-24T02:30:00+00:00',
|
||||||
|
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
|
||||||
|
|
||||||
|
expect($hours)->toBe(0.0);
|
||||||
|
});
|
||||||
|
|
||||||
it('wires the overview route to batched repository methods and overview path', function (): void {
|
it('wires the overview route to batched repository methods and overview path', function (): void {
|
||||||
$routeContent = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php'));
|
$routeContent = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php'));
|
||||||
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
|
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function economic_draft_customer_openapi_specs_or_skip(): array
|
||||||
|
{
|
||||||
|
$root = dirname(__DIR__, 7);
|
||||||
|
$candidates = [
|
||||||
|
$root . DIRECTORY_SEPARATOR . 'backend-php' . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'openapi.yaml',
|
||||||
|
$root . DIRECTORY_SEPARATOR . 'backend-php' . DIRECTORY_SEPARATOR . 'openapi.yaml',
|
||||||
|
$root . DIRECTORY_SEPARATOR . 'front-end-vue' . DIRECTORY_SEPARATOR . 'openapi.yaml',
|
||||||
|
];
|
||||||
|
|
||||||
|
$specs = [];
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if (!is_file($candidate)) {
|
||||||
|
test()->markTestSkipped('One or more tracked openapi.yaml copies are not available in this runtime environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($candidate);
|
||||||
|
if ($content === false) {
|
||||||
|
test()->markTestSkipped('Failed to read one or more tracked openapi.yaml copies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$specs[$candidate] = $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('documents transaction draft customer config and auth runtime fields in all tracked openapi copies', function (): void {
|
||||||
|
foreach (economic_draft_customer_openapi_specs_or_skip() as $path => $content) {
|
||||||
|
expect($content, $path)->toContain('/auth/session:');
|
||||||
|
expect($content, $path)->toContain('transaction_draft_customer_number:');
|
||||||
|
expect($content, $path)->toContain('EconomicConfigEntry:');
|
||||||
|
expect($content, $path)->toContain('transactionDraftCustomerNumber');
|
||||||
|
expect($content, $path)->toContain('nullable: true');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/edge_gateway_agent_artifact_locator.php');
|
||||||
|
app_require('classes/edge_gateway_install_service.php');
|
||||||
|
app_require('classes/edge_gateway_manager.php');
|
||||||
|
|
||||||
|
use classes\edge_gateway_agent_artifact_locator;
|
||||||
|
use classes\edge_gateway_install_service;
|
||||||
|
use classes\edge_gateway_manager;
|
||||||
|
|
||||||
|
class EdgeGatewayInstallServiceHarness extends edge_gateway_install_service
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EdgeGatewayManagerArtifactHarness extends edge_gateway_manager
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function with_edge_gateway_artifact_env(array $values, callable $callback): void
|
||||||
|
{
|
||||||
|
$keys = ['EDGE_AGENT_ARTIFACT_DIR'];
|
||||||
|
$originals = [];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$originals[$key] = getenv($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (array_key_exists($key, $values) && $values[$key] !== null) {
|
||||||
|
putenv($key . '=' . $values[$key]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
putenv($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
$callback();
|
||||||
|
} finally {
|
||||||
|
foreach ($originals as $key => $value) {
|
||||||
|
if ($value === false) {
|
||||||
|
putenv($key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove_edge_gateway_temp_path(string $path): void
|
||||||
|
{
|
||||||
|
if (is_file($path)) {
|
||||||
|
unlink($path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir($path)) {
|
||||||
|
rmdir($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('resolves edge-agent artifacts from a supported runtime layout', function (): void {
|
||||||
|
$path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path());
|
||||||
|
|
||||||
|
expect(str_replace('\\', '/', $path))->toEndWith('/resources/edge-gateway-agent/agent.php');
|
||||||
|
expect(is_file($path))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes router resources before mounted and baked-in artifact directories', function (): void {
|
||||||
|
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void {
|
||||||
|
$candidatePaths = array_map(
|
||||||
|
static fn(string $path): string => str_replace('\\', '/', $path),
|
||||||
|
edge_gateway_agent_artifact_locator::candidatePaths(
|
||||||
|
'agent.php',
|
||||||
|
'/var/www/html',
|
||||||
|
'/services/edge-agent/php-agent',
|
||||||
|
'/opt/truckwash-edge-agent-artifacts'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(array_slice($candidatePaths, 0, 3))->toBe([
|
||||||
|
'/var/www/html/resources/edge-gateway-agent/agent.php',
|
||||||
|
'/services/edge-agent/php-agent/agent.php',
|
||||||
|
'/opt/truckwash-edge-agent-artifacts/agent.php',
|
||||||
|
]);
|
||||||
|
expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php');
|
||||||
|
expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads install artifacts through the shared locator', function (): void {
|
||||||
|
$service = new EdgeGatewayInstallServiceHarness();
|
||||||
|
$contents = $service->readArtifact('truckwash-edge-agent.service');
|
||||||
|
|
||||||
|
expect($contents)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds update payloads with checksums from resolved artifact paths', function (): void {
|
||||||
|
$manager = new EdgeGatewayManagerArtifactHarness();
|
||||||
|
$payload = $manager->buildUpdateOperationRequest('2.0.0', 'stable');
|
||||||
|
|
||||||
|
expect($payload['artifactSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/agent.php')));
|
||||||
|
expect($payload['serviceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-agent.service')));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to baked-in artifacts when the mount path is absent', function (): void {
|
||||||
|
$tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-baked-' . bin2hex(random_bytes(6));
|
||||||
|
$appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app';
|
||||||
|
$missingMountPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent';
|
||||||
|
$bakedPath = $tempRoot . DIRECTORY_SEPARATOR . 'baked-artifacts';
|
||||||
|
$bakedAgentPath = $bakedPath . DIRECTORY_SEPARATOR . 'agent.php';
|
||||||
|
|
||||||
|
@mkdir($appPath, 0777, true);
|
||||||
|
@mkdir($bakedPath, 0777, true);
|
||||||
|
file_put_contents($bakedAgentPath, "<?php\n// baked artifact\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resolvedPath = edge_gateway_agent_artifact_locator::resolve(
|
||||||
|
'agent.php',
|
||||||
|
$appPath,
|
||||||
|
$missingMountPath,
|
||||||
|
$bakedPath
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(str_replace('\\', '/', $resolvedPath))->toBe(str_replace('\\', '/', $bakedAgentPath));
|
||||||
|
} finally {
|
||||||
|
remove_edge_gateway_temp_path($bakedAgentPath);
|
||||||
|
foreach ([
|
||||||
|
$bakedPath,
|
||||||
|
$appPath,
|
||||||
|
dirname($appPath),
|
||||||
|
dirname(dirname($appPath)),
|
||||||
|
dirname($missingMountPath),
|
||||||
|
dirname(dirname($missingMountPath)),
|
||||||
|
$tempRoot,
|
||||||
|
] as $directory) {
|
||||||
|
remove_edge_gateway_temp_path($directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains the legacy dist mount mismatch when php artifacts are unavailable', function (): void {
|
||||||
|
$tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-artifacts-' . bin2hex(random_bytes(6));
|
||||||
|
$appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app';
|
||||||
|
$legacyDistPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist';
|
||||||
|
|
||||||
|
@mkdir($appPath, 0777, true);
|
||||||
|
@mkdir($legacyDistPath, 0777, true);
|
||||||
|
file_put_contents($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs', '// legacy node artifact');
|
||||||
|
|
||||||
|
try {
|
||||||
|
edge_gateway_agent_artifact_locator::resolve('agent.php', $appPath);
|
||||||
|
test()->fail('Expected artifact resolution to fail when php-agent artifacts are missing.');
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
expect($exception->getMessage())->toContain('Missing edge agent artifact: agent.php.');
|
||||||
|
expect($exception->getMessage())->toContain('Deploy the router-owned artifacts under');
|
||||||
|
expect($exception->getMessage())->toContain('Legacy Node dist artifact found at');
|
||||||
|
expect($exception->getMessage())->toContain('Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.');
|
||||||
|
} finally {
|
||||||
|
remove_edge_gateway_temp_path($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs');
|
||||||
|
foreach ([
|
||||||
|
$legacyDistPath,
|
||||||
|
dirname($legacyDistPath),
|
||||||
|
$appPath,
|
||||||
|
dirname($appPath),
|
||||||
|
dirname(dirname($appPath)),
|
||||||
|
$tempRoot,
|
||||||
|
] as $directory) {
|
||||||
|
remove_edge_gateway_temp_path($directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/edge_gateway_manager.php');
|
||||||
|
|
||||||
|
use classes\edge_gateway_manager;
|
||||||
|
|
||||||
|
it('summarizes fleet usage statistics for the dashboard landing view', function (): void {
|
||||||
|
$summary = edge_gateway_manager::summarizeFleetUsage([
|
||||||
|
[
|
||||||
|
'id' => 701,
|
||||||
|
'department_id' => 1,
|
||||||
|
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||||
|
'version_drift' => ['is_drifted' => true],
|
||||||
|
'channel_status' => ['broker' => ['connected' => true]],
|
||||||
|
'active_operation' => ['id' => 91, 'type' => 'DISCOVERY'],
|
||||||
|
'recent_operations_summary' => ['pending' => 1, 'in_progress' => 1],
|
||||||
|
'backlog_depth' => ['operations' => 2, 'commands' => 3],
|
||||||
|
'metadata' => [
|
||||||
|
'system_metrics' => [
|
||||||
|
'latency_ms' => 184,
|
||||||
|
'cpu_usage_pct' => 27,
|
||||||
|
'memory_usage_pct' => 61,
|
||||||
|
'disk_usage_pct' => 58,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 702,
|
||||||
|
'department_id' => 2,
|
||||||
|
'status' => edge_gateway_manager::STATUS_OFFLINE,
|
||||||
|
'version_drift' => ['is_drifted' => false],
|
||||||
|
'channel_status' => ['broker' => ['connected' => false]],
|
||||||
|
'active_operation' => null,
|
||||||
|
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
|
||||||
|
'backlog_depth' => ['operations' => 0, 'commands' => 1],
|
||||||
|
'metadata' => [
|
||||||
|
'system_metrics' => [
|
||||||
|
'latency_ms' => 412,
|
||||||
|
'cpu_usage_pct' => 9,
|
||||||
|
'memory_usage_pct' => 42,
|
||||||
|
'disk_usage_pct' => 76,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], [
|
||||||
|
'total' => 2,
|
||||||
|
'online' => 1,
|
||||||
|
'offline' => 1,
|
||||||
|
], [
|
||||||
|
'total' => 3,
|
||||||
|
'fallback_overrides' => 2,
|
||||||
|
'cloud_only' => 1,
|
||||||
|
'local_only' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($summary['gateways'])->toBe([
|
||||||
|
'total' => 2,
|
||||||
|
'departments' => 2,
|
||||||
|
'online' => 1,
|
||||||
|
'offline' => 1,
|
||||||
|
'degraded' => 0,
|
||||||
|
'drifted' => 1,
|
||||||
|
'broker_connected' => 1,
|
||||||
|
]);
|
||||||
|
expect($summary['inventory'])->toBe([
|
||||||
|
'total' => 2,
|
||||||
|
'online' => 1,
|
||||||
|
'offline' => 1,
|
||||||
|
]);
|
||||||
|
expect($summary['bindings'])->toBe([
|
||||||
|
'total' => 3,
|
||||||
|
'fallback_overrides' => 2,
|
||||||
|
'cloud_only' => 1,
|
||||||
|
'local_only' => 1,
|
||||||
|
]);
|
||||||
|
expect($summary['operations'])->toBe([
|
||||||
|
'active' => 1,
|
||||||
|
'pending' => 1,
|
||||||
|
'in_progress' => 1,
|
||||||
|
'backlog' => 2,
|
||||||
|
]);
|
||||||
|
expect($summary['commands'])->toBe([
|
||||||
|
'backlog' => 4,
|
||||||
|
]);
|
||||||
|
expect($summary['system'])->toBe([
|
||||||
|
'latency_ms_avg' => 298,
|
||||||
|
'cpu_usage_pct_avg' => 18,
|
||||||
|
'memory_usage_pct_avg' => 52,
|
||||||
|
'disk_usage_pct_avg' => 67,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -4,50 +4,45 @@ app_require('classes/edge_gateway_manager.php');
|
|||||||
|
|
||||||
use classes\edge_gateway_manager;
|
use classes\edge_gateway_manager;
|
||||||
|
|
||||||
it('queues admin commands, exposes agent poll/result handlers, and keeps heartbeats from mutating discovery state', function (): void {
|
it('keeps relay dispatch and discovery queueing on the edge gateway manager', function (): void {
|
||||||
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||||
|
$operationServiceSource = file_get_contents(app_path('classes/edge_gateway_operation_service.php'));
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
expect($managerSource)->not->toBeFalse();
|
||||||
expect($source)->toContain('public function pollCommand');
|
expect($managerSource)->toContain('public function queueDiscovery');
|
||||||
expect($source)->toContain('public function submitCommandResult');
|
expect($managerSource)->toContain('public function dispatchRelayStatus');
|
||||||
expect($source)->toContain('private function waitForCommandResult');
|
expect($managerSource)->toContain('public function dispatchRelaySwitch');
|
||||||
expect($source)->toContain('private function claimNextCommandJob');
|
expect($managerSource)->toContain('private function createCommandJob');
|
||||||
expect($source)->toContain("COALESCE(updated_at, created_at, requested_at) <= :stale_before");
|
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
|
||||||
expect($source)->toContain("throw new Exception('Edge gateway command timed out');");
|
expect($managerSource)->toContain('public function rotateGatewayCredentials');
|
||||||
expect($source)->toContain("throw new Exception('Gateway agent is offline');");
|
expect($operationServiceSource)->toContain('public function queueOperation');
|
||||||
expect($source)->toContain("\$gateway->discovery_status->set('PENDING');");
|
expect($operationServiceSource)->toContain('public function claimNextOperation');
|
||||||
expect($source)->toContain("\$gateway->discovery_status->set('READY');");
|
expect($operationServiceSource)->toContain('public function completeAgentOperation');
|
||||||
expect($source)->toContain("\$gateway->discovery_status->set('FAILED');");
|
expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;');
|
||||||
expect($source)->toContain("\$this->buildUpdateCommandPayload(\$targetVersion, \$releaseChannel)");
|
expect($operationServiceSource)->toContain('private function refreshOperationLease');
|
||||||
expect($source)->toContain("'UNINSTALL_AGENT'");
|
expect($operationServiceSource)->toContain('agent_instance_id');
|
||||||
expect($source)->toContain("public function queueUninstall");
|
expect($operationServiceSource)->toContain('lease_expires_at');
|
||||||
expect($source)->toContain("public function deleteGateway");
|
expect($operationServiceSource)->toContain("'operation_type' => \$type");
|
||||||
expect($source)->toContain("private function softDeleteGatewayRelations");
|
expect($managerSource)->toContain("command_type");
|
||||||
expect($source)->toContain("\$updateJob->status->set('DISPATCHING');");
|
|
||||||
expect($source)->toContain("\$updateJob->status->set('VERIFYING');");
|
|
||||||
expect($source)->toContain("\$updateJob->status->set(\$finalStatus ?? (\$ok ? 'COMPLETED' : 'FAILED'));");
|
|
||||||
expect($source)->toContain("\$this->applyHeartbeatUpdateLifecycle(\$gateway, \$payload);");
|
|
||||||
expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defines the dispatchable gateway guard and api-polled shell queue on the loaded manager class', function (): void {
|
it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void {
|
||||||
$reflection = new \ReflectionClass(edge_gateway_manager::class);
|
$reflection = new ReflectionClass(edge_gateway_manager::class);
|
||||||
|
$operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class);
|
||||||
|
|
||||||
expect($reflection->hasMethod('requireDispatchableGateway'))->toBeTrue();
|
|
||||||
expect($reflection->getMethod('requireDispatchableGateway')->isPrivate())->toBeTrue();
|
|
||||||
expect($reflection->hasMethod('queueDiscovery'))->toBeTrue();
|
expect($reflection->hasMethod('queueDiscovery'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('queueUpdate'))->toBeTrue();
|
expect($reflection->hasMethod('pollCommand'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('queueUninstall'))->toBeTrue();
|
expect($reflection->hasMethod('submitCommandResult'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('deleteGateway'))->toBeTrue();
|
|
||||||
expect($reflection->hasMethod('buildUpdateCommandPayload'))->toBeTrue();
|
|
||||||
expect($reflection->hasMethod('applyHeartbeatUpdateLifecycle'))->toBeTrue();
|
|
||||||
expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue();
|
expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue();
|
expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('pollShellAction'))->toBeTrue();
|
expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('submitShellActionResult'))->toBeTrue();
|
expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue();
|
||||||
expect($reflection->hasMethod('recordShellSessionEvents'))->toBeTrue();
|
expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('pollShellSessionEvents'))->toBeTrue();
|
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
|
||||||
expect($reflection->hasMethod('enqueueShellInput'))->toBeTrue();
|
expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('enqueueShellResize'))->toBeTrue();
|
expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('requestShellSessionClose'))->toBeTrue();
|
expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue();
|
||||||
|
expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue();
|
||||||
|
expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue();
|
||||||
|
expect($operationServiceReflection->hasMethod('completeAgentOperation'))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function
|
|||||||
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
|
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('derives relay fallback and transport health details for degraded hybrid control planes', function (): void {
|
it('derives relay fallback and transport health details without shell or update runtime state', function (): void {
|
||||||
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
|
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
|
||||||
'status' => edge_gateway_manager::STATUS_ONLINE,
|
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||||
'last_heartbeat_at' => '2026-04-08 10:04:30',
|
'last_heartbeat_at' => '2026-04-08 10:04:30',
|
||||||
@@ -82,8 +82,13 @@ it('derives relay fallback and transport health details for degraded hybrid cont
|
|||||||
],
|
],
|
||||||
'operational_snapshot' => [
|
'operational_snapshot' => [
|
||||||
'command_backlog' => 2,
|
'command_backlog' => 2,
|
||||||
'shell_backlog' => 1,
|
'operation_backlog' => 1,
|
||||||
'update_backlog' => 1,
|
],
|
||||||
|
'active_operation' => [
|
||||||
|
'id' => 91,
|
||||||
|
'type' => 'DISCOVERY',
|
||||||
|
'status' => 'IN_PROGRESS',
|
||||||
|
'started_at' => '2026-04-08 09:30:00',
|
||||||
],
|
],
|
||||||
'bindings' => [
|
'bindings' => [
|
||||||
[
|
[
|
||||||
@@ -113,15 +118,11 @@ it('derives relay fallback and transport health details for degraded hybrid cont
|
|||||||
'completed_at' => '2026-04-08 10:02:00',
|
'completed_at' => '2026-04-08 10:02:00',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'recent_shell_sessions' => [
|
|
||||||
[
|
|
||||||
'opened_at' => '2026-04-08 10:03:00',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], strtotime('2026-04-08 10:05:00'));
|
], strtotime('2026-04-08 10:05:00'));
|
||||||
|
|
||||||
expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
|
expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
|
||||||
expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
|
expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
|
||||||
|
expect($gateway['channel_status'])->not->toHaveKey('shell');
|
||||||
expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud');
|
expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud');
|
||||||
expect($gateway['relay_health'][0]['reason'])->toBe('device_stale');
|
expect($gateway['relay_health'][0]['reason'])->toBe('device_stale');
|
||||||
expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery');
|
expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery');
|
||||||
@@ -132,10 +133,10 @@ it('derives relay fallback and transport health details for degraded hybrid cont
|
|||||||
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
|
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
|
||||||
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
|
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
|
||||||
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
|
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
|
||||||
expect($gateway['last_successful_shell_at'])->toBe('2026-04-08 10:03:00');
|
expect($gateway['diagnostics'])->not->toBeEmpty();
|
||||||
|
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT');
|
||||||
expect($gateway['backlog_depth'])->toBe([
|
expect($gateway['backlog_depth'])->toBe([
|
||||||
'commands' => 2,
|
'commands' => 2,
|
||||||
'shell_actions' => 1,
|
'operations' => 1,
|
||||||
'updates' => 1,
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
|||||||
{
|
{
|
||||||
$originalServer = $_SERVER;
|
$originalServer = $_SERVER;
|
||||||
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
|
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
|
||||||
$originalPublicBrokerUrl = getenv('EDGE_PUBLIC_BROKER_URL');
|
|
||||||
|
|
||||||
$_SERVER = $server;
|
$_SERVER = $server;
|
||||||
|
|
||||||
@@ -28,15 +27,10 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
|||||||
} else {
|
} else {
|
||||||
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
|
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
|
||||||
}
|
}
|
||||||
if ($originalPublicBrokerUrl === false) {
|
|
||||||
putenv('EDGE_PUBLIC_BROKER_URL');
|
|
||||||
} else {
|
|
||||||
putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('builds install script urls with forwarded https scheme when proxied', function (): void {
|
it('builds install script urls with the PHP agent artifacts and forwarded https scheme', function (): void {
|
||||||
with_edge_gateway_server_state([
|
with_edge_gateway_server_state([
|
||||||
'HTTP_HOST' => 'api.truckwash.io:4433',
|
'HTTP_HOST' => 'api.truckwash.io:4433',
|
||||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||||
@@ -48,18 +42,17 @@ it('builds install script urls with forwarded https scheme when proxied', functi
|
|||||||
expect($manager->buildInstallTokenVerifyUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123');
|
expect($manager->buildInstallTokenVerifyUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123');
|
||||||
expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123');
|
expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123');
|
||||||
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
||||||
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123" >/dev/null');
|
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
|
||||||
expect($script)->toContain('mkdir -p "$INSTALL_DIR"');
|
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
|
||||||
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/package.json" -o "$INSTALL_DIR/package.json"');
|
expect($script)->toContain('fetch_http "Download systemd service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"');
|
||||||
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/agent.mjs" -o "$INSTALL_DIR/agent.mjs"');
|
expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}');
|
||||||
|
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
|
||||||
|
expect($script)->toContain('Response body preview (first 400 bytes):');
|
||||||
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
||||||
expect($script)->toContain('"installDir":"/opt/truckwash-edge-agent"');
|
|
||||||
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
||||||
expect($script)->toContain('"commandPollTimeoutSeconds":20');
|
expect($script)->toContain('"operationPollTimeoutSeconds":20');
|
||||||
expect($script)->toContain('"shellActionPollTimeoutSeconds":20');
|
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
|
||||||
expect($script)->toContain('"updateVerificationTimeoutSeconds":45');
|
expect($script)->not->toContain('"brokerUrl"');
|
||||||
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
|
|
||||||
expect($script)->not->toContain('Undefined variable $INSTALL_DIR');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,47 +68,19 @@ it('appends forwarded ports when the forwarded host omits them', function (): vo
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('infers https for the staging api host when only the https port is present', function (): void {
|
|
||||||
with_edge_gateway_server_state([
|
|
||||||
'HTTP_HOST' => 'api.truckwash.io:4433',
|
|
||||||
'SERVER_PORT' => '4433',
|
|
||||||
], function (): void {
|
|
||||||
$manager = new EdgeGatewayManagerUrlHarness();
|
|
||||||
$script = $manager->buildInstallScript('port-only');
|
|
||||||
|
|
||||||
expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433');
|
|
||||||
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
|
||||||
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
|
||||||
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
|
it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
|
||||||
with_edge_gateway_server_state([
|
with_edge_gateway_server_state([
|
||||||
'HTTP_HOST' => 'localhost',
|
'HTTP_HOST' => 'localhost',
|
||||||
'HTTP_X_FORWARDED_PROTO' => 'http',
|
'HTTP_X_FORWARDED_PROTO' => 'http',
|
||||||
], function (): void {
|
], function (): void {
|
||||||
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
|
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
|
||||||
putenv('EDGE_PUBLIC_BROKER_URL=https://broker.edge.example.test');
|
|
||||||
|
|
||||||
$manager = new EdgeGatewayManagerUrlHarness();
|
$manager = new EdgeGatewayManagerUrlHarness();
|
||||||
|
$script = $manager->buildInstallScript('token-1');
|
||||||
|
|
||||||
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
|
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
|
||||||
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
|
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
|
||||||
expect($manager->buildInstallScript('token-1'))->toContain('"brokerUrl":"https://broker.edge.example.test"');
|
expect($script)->toContain('"apiUrl":"https://edge.example.test/api"');
|
||||||
});
|
expect($script)->not->toContain('"brokerUrl"');
|
||||||
});
|
|
||||||
|
|
||||||
it('includes node-pty build prerequisites in the generated install script', function (): void {
|
|
||||||
with_edge_gateway_server_state([
|
|
||||||
'HTTP_HOST' => 'api.truckwash.io:4433',
|
|
||||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
|
||||||
], function (): void {
|
|
||||||
$manager = new EdgeGatewayManagerUrlHarness();
|
|
||||||
$script = $manager->buildInstallScript('pty-token');
|
|
||||||
|
|
||||||
expect($script)->toMatch('/set -euo pipefail\s+curl -fsSL "https:\/\/api\.truckwash\.io:4433\/edge-agent\/install-token\/verify\?token=pty-token" >\/dev\/null\s+INSTALL_DIR=\/opt\/truckwash-edge-agent/');
|
|
||||||
expect($script)->toContain('apt-get install -y curl ca-certificates nodejs npm python3 make g++');
|
|
||||||
expect($script)->toContain('npm install --omit=dev');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/edge_gateway_manager.php');
|
||||||
|
|
||||||
|
use classes\edge_gateway_manager;
|
||||||
|
|
||||||
|
it('adds a gateway metadata update endpoint with label and primary assignment fields', function (): void {
|
||||||
|
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||||
|
|
||||||
|
expect($routeSource)->not->toBeFalse();
|
||||||
|
expect($routeSource)->toContain("put('/edge-gateways/{id}'");
|
||||||
|
expect($routeSource)->toContain("self::requireParameters(['label', 'is_primary']);");
|
||||||
|
expect($routeSource)->toContain("self::requireType(self::getParameter('label'), self::TYPE_STRING());");
|
||||||
|
expect($routeSource)->toContain("self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());");
|
||||||
|
expect($routeSource)->toContain('updateGatewayMetadata(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reassigns department primary gateways through dedicated manager helpers', function (): void {
|
||||||
|
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||||
|
$reflection = new ReflectionClass(edge_gateway_manager::class);
|
||||||
|
|
||||||
|
expect($source)->not->toBeFalse();
|
||||||
|
expect($source)->toContain("\$this->setGatewayPrimaryState(\$gateway, true);");
|
||||||
|
expect($source)->toContain("\$replacement = \$this->findAlternateGatewayForDepartment(");
|
||||||
|
expect($source)->toContain("throw new Exception('Department must retain a primary gateway');");
|
||||||
|
expect($reflection->hasMethod('updateGatewayMetadata'))->toBeTrue();
|
||||||
|
expect($reflection->hasMethod('setGatewayPrimaryState'))->toBeTrue();
|
||||||
|
expect($reflection->hasMethod('findAlternateGatewayForDepartment'))->toBeTrue();
|
||||||
|
});
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('keeps the legacy operation_type column compatible with v2 operation queueing', function (): void {
|
||||||
|
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
|
||||||
|
$operationServiceContent = file_get_contents(app_path('classes/edge_gateway_operation_service.php'));
|
||||||
|
$operationObjectContent = file_get_contents(app_path('objects/edge_gateway_operations_o.php'));
|
||||||
|
|
||||||
|
expect($bootstrapContent)->not->toBeFalse();
|
||||||
|
expect($operationServiceContent)->not->toBeFalse();
|
||||||
|
expect($operationObjectContent)->not->toBeFalse();
|
||||||
|
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")");
|
||||||
|
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
|
||||||
|
expect($bootstrapContent)->toContain('SET type = operation_type');
|
||||||
|
expect($operationServiceContent)->toContain("'operation_type' => \$type");
|
||||||
|
expect($operationObjectContent)->toContain("new object_property(\$this->table, \$this->id, 'operation_type', 'string', false)");
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('registers the edge gateway management REST endpoints', function (): void {
|
it('registers the v2 operator-facing edge gateway routes', function (): void {
|
||||||
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||||
|
|
||||||
expect($route)->not->toBeFalse();
|
expect($route)->not->toBeFalse();
|
||||||
@@ -9,42 +9,32 @@ it('registers the edge gateway management REST endpoints', function (): void {
|
|||||||
expect($route)->toContain("'/edge-gateways/install-token'");
|
expect($route)->toContain("'/edge-gateways/install-token'");
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
|
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
|
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/update-jobs'");
|
expect($route)->toContain("'/edge-gateways/{id}/operations'");
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/uninstall'");
|
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'");
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
|
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
|
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
|
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
|
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
|
|
||||||
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
||||||
expect($route)->toContain("'/edge-gateways/{id}'");
|
|
||||||
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
|
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
|
||||||
|
expect($route)->toContain("add_meta('fleet_usage'");
|
||||||
|
expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||||
|
expect($route)->not->toContain('private function requirePermission');
|
||||||
|
expect($route)->not->toContain('private function requireDepartmentAccess');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers public installer, claim, heartbeat, command polling, and shell polling endpoints', function (): void {
|
it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void {
|
||||||
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||||
|
|
||||||
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
||||||
expect($route)->toContain("'/edge-agent/install.sh'");
|
expect($route)->toContain("'/edge-agent/install.sh'");
|
||||||
expect($route)->toContain("'/edge-agent/artifacts/agent.mjs'");
|
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
|
||||||
expect($route)->toContain("'/edge-agent/artifacts/package.json'");
|
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'");
|
||||||
expect($route)->toContain("'/edge-agent/claim'");
|
expect($route)->toContain("'/edge-agent/claim'");
|
||||||
expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'");
|
expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'");
|
||||||
|
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/next'");
|
||||||
|
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/events'");
|
||||||
|
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/complete'");
|
||||||
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
|
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
|
||||||
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
|
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
|
||||||
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
expect($route)->toContain("'/edge-agent/gateways/{id}/presence'");
|
||||||
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
|
expect($route)->toContain('echo $exception->getMessage()');
|
||||||
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||||
});
|
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||||
|
|
||||||
it('registers authenticated internal broker validation and presence endpoints', function (): void {
|
|
||||||
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
|
||||||
|
|
||||||
expect($route)->not->toBeFalse();
|
|
||||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
|
|
||||||
expect($route)->toContain("'/edge-agent/internal/shell-sessions/validate'");
|
|
||||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
|
|
||||||
expect($route)->toContain("'/edge-agent/internal/shell-sessions/close'");
|
|
||||||
expect($route)->toContain('HTTP_X_EDGE_BROKER_SECRET');
|
|
||||||
expect($route)->toContain('EDGE_BROKER_SHARED_SECRET');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('defines the edge gateway runtime schema bootstrap tables', function (): void {
|
it('defines the v2 edge gateway schema bootstrap tables', function (): void {
|
||||||
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
|
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
|
||||||
|
|
||||||
expect($bootstrapContent)->not->toBeFalse();
|
expect($bootstrapContent)->not->toBeFalse();
|
||||||
@@ -9,24 +9,38 @@ it('defines the edge gateway runtime schema bootstrap tables', function (): void
|
|||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_device_inventory');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_device_inventory');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_relay_bindings');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_relay_bindings');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_command_jobs');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_command_jobs');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs');
|
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_events');
|
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
|
||||||
|
expect($bootstrapContent)->not->toContain('edge_gateway_shell_sessions');
|
||||||
|
expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs');
|
||||||
|
expect($bootstrapContent)->not->toContain('edge_gateway_shell_events');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores edge gateway heartbeats, bindings, shell transcripts, and shell polling queues', function (): void {
|
it('stores operation metadata and event timelines for management workflows', function (): void {
|
||||||
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
|
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
|
||||||
|
|
||||||
expect($bootstrapContent)->toContain('last_heartbeat_at DATETIME NULL');
|
expect($bootstrapContent)->toContain('command_type VARCHAR(64) NOT NULL');
|
||||||
expect($bootstrapContent)->toContain('command_job_id INT NULL');
|
expect($bootstrapContent)->toContain('delivery_json JSON NULL');
|
||||||
expect($bootstrapContent)->toContain('channel INT NOT NULL DEFAULT 0');
|
expect($bootstrapContent)->toContain("fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL'");
|
||||||
expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL');
|
expect($bootstrapContent)->toContain('type VARCHAR(32) NOT NULL');
|
||||||
expect($bootstrapContent)->toContain('action_type VARCHAR(32) NOT NULL');
|
expect($bootstrapContent)->toContain("operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY'");
|
||||||
expect($bootstrapContent)->toContain('payload_json JSON NULL');
|
expect($bootstrapContent)->toContain('agent_instance_id VARCHAR(128) NULL');
|
||||||
expect($bootstrapContent)->toContain('event_type VARCHAR(32) NOT NULL');
|
expect($bootstrapContent)->toContain('lease_expires_at DATETIME NULL');
|
||||||
|
expect($bootstrapContent)->toContain('last_progress_at DATETIME NULL');
|
||||||
|
expect($bootstrapContent)->toContain('attempt_count INT NOT NULL DEFAULT 0');
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id\")");
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")");
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json')");
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id')");
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id')");
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at')");
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at')");
|
||||||
|
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
|
||||||
|
expect($bootstrapContent)->toContain('SET type = operation_type');
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
|
||||||
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
|
||||||
|
expect($bootstrapContent)->toContain('summary_json JSON NULL');
|
||||||
expect($bootstrapContent)->toContain('context_json JSON NULL');
|
expect($bootstrapContent)->toContain('context_json JSON NULL');
|
||||||
expect(substr_count($bootstrapContent, 'delivery_json JSON NULL'))->toBeGreaterThanOrEqual(3);
|
expect($bootstrapContent)->not->toContain('session_token_hash CHAR(64) NOT NULL');
|
||||||
expect($bootstrapContent)->toContain('ADD COLUMN delivery_json JSON NULL');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('rewrites edge gateway shell transport to API polling queues', function (): void {
|
it('removes shell access from the edge gateway HTTP contracts', function (): void {
|
||||||
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
|
||||||
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||||
|
|
||||||
expect($managerSource)->not->toBeFalse();
|
|
||||||
expect($routeSource)->not->toBeFalse();
|
expect($routeSource)->not->toBeFalse();
|
||||||
|
|
||||||
expect($managerSource)->toContain("'transport' => self::DELIVERY_CHANNEL_API");
|
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||||
expect($managerSource)->toContain("'transport_path' => self::DELIVERY_CHANNEL_API");
|
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
|
||||||
expect($managerSource)->toContain("'reconnect_state' => 'PENDING'");
|
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
|
||||||
expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API");
|
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
|
||||||
expect($managerSource)->toContain('private function createShellActionJob');
|
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||||
expect($managerSource)->toContain('private function claimNextShellActionJob');
|
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
|
||||||
expect($managerSource)->toContain('private function appendShellEvent');
|
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||||
expect($managerSource)->toContain('private function listShellEvents');
|
|
||||||
expect($managerSource)->toContain('public function pollShellAction');
|
|
||||||
expect($managerSource)->toContain('public function submitShellActionResult');
|
|
||||||
expect($managerSource)->toContain('public function recordShellSessionEvents');
|
|
||||||
expect($managerSource)->not->toContain('buildBrowserShellWsUrl');
|
|
||||||
expect($managerSource)->not->toContain('edge_broker_client');
|
|
||||||
|
|
||||||
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
|
|
||||||
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
|
|
||||||
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
|
|
||||||
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
|
|
||||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
|
||||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
|
|
||||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
|
||||||
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
|
|
||||||
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/validate'");
|
|
||||||
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
|
|
||||||
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/close'");
|
|
||||||
expect($routeSource)->toContain('requireBrokerSharedSecret');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('builds update command payloads with artifact urls, checksums, and the service name', function (): void {
|
it('builds the installer around the PHP agent artifacts and management polling config', function (): void {
|
||||||
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||||
|
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
expect($managerSource)->not->toBeFalse();
|
||||||
expect($source)->toContain("'artifactUrl' => \$this->buildAgentArtifactUrl('agent.mjs')");
|
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
|
||||||
expect($source)->toContain("'artifactSha256' => \$this->buildAgentArtifactSha256('agent.mjs')");
|
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
|
||||||
expect($source)->toContain("'packageUrl' => \$this->buildAgentArtifactUrl('package.json')");
|
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
|
||||||
expect($source)->toContain("'packageSha256' => \$this->buildAgentArtifactSha256('package.json')");
|
expect($managerSource)->toContain('fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"');
|
||||||
expect($source)->toContain("'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME");
|
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
|
||||||
|
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
|
||||||
|
expect($managerSource)->toContain('apt-get install -y curl ca-certificates php-cli php-curl php-mbstring');
|
||||||
|
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
|
||||||
|
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
|
||||||
|
expect($managerSource)->toContain('systemctl enable truckwash-edge-agent.service');
|
||||||
|
expect($managerSource)->toContain('systemctl restart truckwash-edge-agent.service');
|
||||||
|
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-agent.service');
|
||||||
|
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||||
|
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||||
|
expect($managerSource)->toContain('journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true');
|
||||||
|
expect($managerSource)->not->toContain('agent.mjs');
|
||||||
|
expect($managerSource)->not->toContain('"brokerUrl"');
|
||||||
|
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
|
||||||
|
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finalizes active update jobs from heartbeat metadata when the agent reports completion or rollback', function (): void {
|
it('exposes update payload, credential rotation, and operation endpoints without shell transport wiring', function (): void {
|
||||||
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||||
|
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||||
|
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
|
||||||
expect($source)->toContain("\$state = strtoupper(trim((string)(\$lastUpdate['state'] ?? '')));");
|
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
|
||||||
expect($source)->toContain("if (!in_array(\$state, ['COMPLETED', 'FAILED', 'ROLLED_BACK'], true)) {");
|
expect($managerSource)->toContain('public function rotateGatewayCredentials');
|
||||||
expect($source)->toContain("\$updateJob->status->set(\$state);");
|
expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
||||||
expect($source)->toContain("'heartbeat_installed_version' => \$payload['installed_version'] ?? \$gateway->installed_version->value()");
|
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'");
|
||||||
expect($source)->toContain("'heartbeat_target_version' => \$payload['target_version'] ?? \$gateway->target_version->value()");
|
expect($agentSource)->toContain("/operations/next");
|
||||||
|
expect($agentSource)->toContain("/operations/' . \$operationId . '/complete");
|
||||||
|
expect($agentSource)->toContain('last-heartbeat-ok.txt');
|
||||||
|
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||||
|
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ it('calculates workfeed employee hours across multiple departments for one hour
|
|||||||
expect($hours)->toBe(2.0);
|
expect($hours)->toBe(2.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts overtime minutes when a shift carries an extended approval end timestamp', function (): void {
|
it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void {
|
||||||
$route = new moduleWeatherAPIRoute();
|
$route = new moduleWeatherAPIRoute();
|
||||||
$slot = new DateTime('2026-03-23T18:00:00+00:00');
|
$slot = new DateTime('2026-03-23T18:00:00+00:00');
|
||||||
|
|
||||||
@@ -116,9 +116,9 @@ it('counts overtime minutes when a shift carries an extended approval end timest
|
|||||||
(object)[
|
(object)[
|
||||||
'departmentID' => 'dep_1',
|
'departmentID' => 'dep_1',
|
||||||
'start' => '2026-03-23T10:00:00+00:00',
|
'start' => '2026-03-23T10:00:00+00:00',
|
||||||
'end' => '2026-03-23T18:00:00+00:00',
|
'end' => '2026-03-23T18:17:00+00:00',
|
||||||
'approval' => (object)[
|
'approval' => (object)[
|
||||||
'originalEnd' => '2026-03-23T18:17:00+00:00',
|
'originalEnd' => '2026-03-23T18:00:00+00:00',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -128,6 +128,26 @@ it('counts overtime minutes when a shift carries an extended approval end timest
|
|||||||
expect($hours)->toBe(0.28);
|
expect($hours)->toBe(0.28);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not count removed approved time when a saved shift end is shortened', function (): void {
|
||||||
|
$route = new moduleWeatherAPIRoute();
|
||||||
|
$slot = new DateTime('2026-03-23T17:00:00+00:00');
|
||||||
|
|
||||||
|
$shifts = [
|
||||||
|
(object)[
|
||||||
|
'departmentID' => 'dep_1',
|
||||||
|
'start' => '2026-03-23T10:00:00+00:00',
|
||||||
|
'end' => '2026-03-23T17:00:00+00:00',
|
||||||
|
'approval' => (object)[
|
||||||
|
'originalEnd' => '2026-03-23T18:00:00+00:00',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
|
||||||
|
|
||||||
|
expect($hours)->toBe(0.0);
|
||||||
|
});
|
||||||
|
|
||||||
it('counts unapproved overtime from a bounded shift updateTime fallback', function (): void {
|
it('counts unapproved overtime from a bounded shift updateTime fallback', function (): void {
|
||||||
$route = new moduleWeatherAPIRoute();
|
$route = new moduleWeatherAPIRoute();
|
||||||
$slot = new DateTime('2026-03-23T17:00:00+00:00');
|
$slot = new DateTime('2026-03-23T17:00:00+00:00');
|
||||||
@@ -166,6 +186,27 @@ it('does not treat late unapproved administrative edits as overtime', function (
|
|||||||
expect($hours)->toBe(0.0);
|
expect($hours)->toBe(0.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caps current-slot hours to elapsed minutes and zeroes future slots', function (): void {
|
||||||
|
$route = new moduleWeatherAPIRoute();
|
||||||
|
$currentSlot = new DateTime('2026-03-24T13:00:00+00:00');
|
||||||
|
$futureSlot = new DateTime('2026-03-24T14:00:00+00:00');
|
||||||
|
$occurredUntil = new DateTime('2026-03-24T13:15:00+00:00');
|
||||||
|
|
||||||
|
$shifts = [
|
||||||
|
(object)[
|
||||||
|
'departmentID' => 'dep_1',
|
||||||
|
'start' => '2026-03-24T13:00:00+00:00',
|
||||||
|
'end' => '2026-03-24T15:00:00+00:00',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$currentHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $currentSlot, $occurredUntil]);
|
||||||
|
$futureHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $futureSlot, $occurredUntil]);
|
||||||
|
|
||||||
|
expect($currentHours)->toBe(0.25);
|
||||||
|
expect($futureHours)->toBe(0.0);
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes department id input from scalar csv and nested array values', function (): void {
|
it('normalizes department id input from scalar csv and nested array values', function (): void {
|
||||||
$route = new moduleWeatherAPIRoute();
|
$route = new moduleWeatherAPIRoute();
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ trait module_config_t
|
|||||||
{
|
{
|
||||||
// Check if the request has a variable name and value
|
// Check if the request has a variable name and value
|
||||||
global $response;
|
global $response;
|
||||||
if ($response->getRequestParameter('variable') === null || $response->getRequestParameter('value') === null) {
|
if ($response->getRequestParameter('variable') === null || !$response->isRequestParameterSet('value')) {
|
||||||
$response->error('Variable and value not set', 400);
|
$response->error('Variable and value not set', 400);
|
||||||
}
|
}
|
||||||
// Get the variable name and value
|
// Get the variable name and value
|
||||||
@@ -144,6 +144,9 @@ trait module_config_t
|
|||||||
{
|
{
|
||||||
// If the variable is an int, convert it to an int
|
// If the variable is an int, convert it to an int
|
||||||
if ($type == 'integer' || $type == 'int') {
|
if ($type == 'integer' || $type == 'int') {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (integer)$value;
|
return (integer)$value;
|
||||||
}
|
}
|
||||||
// If the variable is a boolean, convert it to a boolean
|
// If the variable is a boolean, convert it to a boolean
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ trait module_config_variable
|
|||||||
*/
|
*/
|
||||||
function validateVariableValue(mixed $value): bool
|
function validateVariableValue(mixed $value): bool
|
||||||
{
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return !$this->config_variable_required;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === '' && !$this->config_variable_required && $this->config_variable_type === 'int') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the value is empty and the variable is required
|
// Check if the value is empty and the variable is required
|
||||||
if ($this->config_variable_required && empty($value)) {
|
if ($this->config_variable_required && empty($value)) {
|
||||||
// If the value is empty and the type is not a boolean, return false
|
// If the value is empty and the type is not a boolean, return false
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
[24-Mar-2026 10:12:39 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
|
[24-Mar-2026 10:12:39 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
|
||||||
[01-Apr-2026 10:49:51 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
|
[01-Apr-2026 10:49:51 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
|
||||||
[07-Apr-2026 14:28:09 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
|
[07-Apr-2026 14:28:09 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
|
||||||
|
[20-Apr-2026 12:45:29 UTC] PHP Parse error: syntax error, unexpected end of file in Command line code on line 1
|
||||||
|
|||||||
Reference in New Issue
Block a user