From 2ecae070a29c9e794490f66a6e09763ed48f6fda Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 16 Mar 2026 13:06:17 +0100 Subject: [PATCH] Add documentation for the self-serve module, detailing architecture, flow, API routes, domain model, runtime tables, and eligibility rules. Includes end-to-end examples and PHP usage. --- .../nginx/app/modules/selfserve/selfserve.md | 1035 +++++++++++++++++ 1 file changed, 1035 insertions(+) create mode 100644 services/nginx/app/modules/selfserve/selfserve.md diff --git a/services/nginx/app/modules/selfserve/selfserve.md b/services/nginx/app/modules/selfserve/selfserve.md new file mode 100644 index 00000000..69bb1fa2 --- /dev/null +++ b/services/nginx/app/modules/selfserve/selfserve.md @@ -0,0 +1,1035 @@ +# Self-Serve Module + +This document explains the current self-serve implementation in `services/nginx/app/modules/selfserve`. + +It is written for two audiences: + +- API consumers who need to configure or call the self-serve endpoints. +- Engineers who need to extend the module without reverse-engineering the whole flow. + +`openapi.yaml` in the repository root remains the contract source of truth. This guide explains how the module behaves at runtime and how the pieces fit together. + +## What The Module Does + +The self-serve module lets a customer answer eligibility questions for a vehicle, derives the tasks that apply to the lane, enables the machine relay when the answers allow it, records the physical machine start through a webhook, bills the wash when the lane is stopped, and keeps a session summary with questions, answers, tasks, and lifecycle events. + +At a high level: + +1. Shared self-serve questions are loaded. +2. Machine-type-specific conditions, rules, and tasks are resolved for the lane. +3. Vehicle answers are evaluated into condition results. +4. If all visible questions are answered and an active task exposes the `MACHINE` service, the lane relay is enabled. +5. When the physical machine button is pressed, `/relay/button/press/post` records the machine start. +6. When the lane is stopped, the wash is invoiced and the latest open self-serve session is completed with the generated order id. + +## Main Flow + +```mermaid +sequenceDiagram + autonumber + participant Customer + participant API as "Department Self-Serve API" + participant Flow as "selfserve_wash_flow" + participant Lane as "selfserve_lane" + participant Shelly as "Shelly relay" + participant Scanner as "Plate scanner webhook" + + Customer->>API: GET /department/selfserve/vehicle/allowed?lane_id=12®=AB12345 + API->>Flow: previewVehicleEligibility() + Flow-->>API: Questions, tasks, allowed=false/true + API-->>Customer: Eligibility preview + + Customer->>API: POST /department/selfserve/vehicle/conditions + API->>Flow: synchronizeSession() + Flow->>Lane: setLaneCache(allowed_services) + Flow->>Lane: turnOnRelay(MACHINE) + Lane->>Shelly: switch(true) + Flow-->>API: Session summary with MACHINE_RELAY_ENABLED event + API-->>Customer: Updated summary + + Scanner->>API: POST /relay/button/press/post + API->>Flow: recordMachineStartWebhook() + Flow->>Lane: set status/state, reg, customer, wash timer + Flow-->>API: Session summary with MACHINE_START_TRIGGERED event + + Customer->>API: POST /modules/self-serve/lane/command (STOP) + API->>Lane: execute(STOP) + Lane->>Lane: invoice() and open exit port + Lane->>Flow: completeLatestSessionForLane(order_id) + Flow-->>API: Session summary with SESSION_COMPLETED event + API-->>Customer: Lane reset response +``` + +## Architecture + +```mermaid +flowchart LR + Q["department_selfserve_questions
shared questions preferred"] --> F["selfserve_wash_flow"] + VC["department_selfserve_vehicle_conditions
vehicle answers"] --> F + C["department_selfserve_conditions
machine-type or legacy"] --> E["selfserve_condition_evaluator"] + R["department_selfserve_condition_rules"] --> E + T["department_selfserve_tasks
machine-type or legacy"] --> F + MT["selfserve_machine_types"] --> L["department_lanes.machine_type_id"] + E --> F + L --> F + F --> SL["selfserve_lane"] + SL --> SH["Shelly machine relay"] + F --> S["selfserve_wash_sessions"] + F --> SA["selfserve_wash_session_answers"] + F --> ST["selfserve_wash_session_tasks"] + F --> SE["selfserve_wash_session_events"] +``` + +## Domain Model + +### Entity Roles + +| Concept | Main object/class | Scope | Purpose | +| --- | --- | --- | --- | +| Shared questions | `department_selfserve_questions_o` | Shared rows are preferred over legacy lane/product rows | Defines the yes/no questions the customer must answer. | +| Conditions | `department_selfserve_conditions_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Groups rules into named boolean gates. | +| Condition rules | `department_selfserve_condition_rules_o` | Attached to a condition | Evaluates question answers or nested conditions. | +| Tasks | `department_selfserve_tasks_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Drives the visible self-serve tasks and exposed services such as `MACHINE`. | +| Machine types | `selfserve_machine_types_o` | Reusable across lanes | Lets multiple lanes share the same conditions and tasks. | +| Vehicle answers | `department_selfserve_vehicle_conditions_o` | Per department, lane, registration, and question | Stores the customer's answers. | +| Wash session | `selfserve_wash_sessions_o` | Per lane and vehicle | Tracks current wash lifecycle, timestamps, machine flags, and billing order. | +| Session answers | `selfserve_wash_session_answers_o` | Per session | Snapshot of the answered visible questions. | +| Session tasks | `selfserve_wash_session_tasks_o` | Per session | Snapshot of the active tasks and attached services/buttons. | +| Session events | `selfserve_wash_session_events_o` | Per session | Audit trail for sync, relay enable, machine start, and completion. | +| Lane runtime | `selfserve_lane` | Per lane | Applies lane status/state changes, relay control, billing, and STOP/RESET behavior. | + +### Scoping Rules + +- Questions should now be defined as shared questions. `selfserve_wash_flow::loadQuestions()` first calls `department_selfserve_questions_o::getSharedQuestions()`. If shared questions exist, they are used for every department and lane. +- Conditions and tasks are machine-type-first. If a lane has `department_lanes.machine_type_id` and matching rows exist, `selfserve_wash_flow` uses those rows and ignores the legacy lane/product rows. +- Legacy fallback still exists. If a lane has no machine type or there are no machine-type-specific rows, the flow falls back to the old department/lane/product plus vehicle-type lookup. +- Vehicle answers are still stored per department, lane, registration number, and question. Those answers feed both direct task gates and nested condition evaluation. + +### Runtime Status Tables + +#### Wash Session Status + +| Status | Meaning | +| --- | --- | +| `PENDING_QUESTIONS` | At least one visible question has no answer yet. | +| `READY_FOR_MACHINE_START` | The snapshot is eligible and ready to enable or start the machine. | +| `MACHINE_NOT_ALLOWED` | All visible questions are answered, but the lane cannot start the machine. | +| `MACHINE_RELAY_ENABLED` | The relay has already been enabled for the session. | +| `MACHINE_STARTED` | The physical machine start webhook has been recorded. | +| `COMPLETED` | The STOP flow completed the session, optionally with an `order_id`. | + +#### Wash Event Types + +| Event | Meaning | +| --- | --- | +| `SESSION_SYNCED` | A snapshot was written to the current session. | +| `MACHINE_RELAY_ENABLED` | The relay was enabled because the snapshot allowed machine start. | +| `MACHINE_START_TRIGGERED` | The physical machine start webhook was recorded. | +| `SESSION_COMPLETED` | The latest open session for the lane was closed, usually from STOP. | + +#### Lane Status + +| Status | Meaning | +| --- | --- | +| `AVAILABLE` | Lane is free and ready. | +| `OCCUPIED` | Lane is currently in use. | +| `RESERVED` | Lane is reserved but not yet started. | +| `FAULT` | Lane cannot be used until the fault is cleared. | +| `MAINTENANCE` | Lane is intentionally unavailable. | +| `CLOSED` | Lane is closed. | + +#### Lane State + +| State | Meaning | +| --- | --- | +| `IDLE` | Resting lane state. | +| `ENTRANCE_PORT_OPEN_QUEUED` | Entrance gate open is queued. | +| `ENTRANCE_PORT_OPEN` | Entrance gate is open. | +| `MACHINE_RELAY_ON_QUEUED` | Machine relay enable is queued. | +| `MACHINE_RELAY_ON` | Machine relay is on. | +| `MACHINE_RELAY_OFF_QUEUED` | Machine relay disable is queued. | +| `MACHINE_RELAY_OFF` | Machine relay is off. | +| `IN_WASH` | The machine has started or the wash is in progress. | +| `EXIT_PORT_OPEN_QUEUED` | Exit gate open is queued. | +| `EXIT_PORT_OPEN` | Exit gate is open. | +| `FAULT` | Lane fault state. | +| `MAINTENANCE` | Maintenance state. | +| `CLOSED` | Closed state. | + +## Eligibility Rules + +`selfserve_wash_flow` allows machine start only when all of these are true: + +1. Every visible question has an answer. +2. The lane has `relay_machine_id`. +3. At least one active task exposes the `MACHINE` service. + +Visible questions are determined by question `condition_id` gates. Active tasks are determined by `selfserve_condition_evaluator::taskGateSatisfied()`. + +`selfserve_condition_evaluator` uses these rules: + +- Non-`IS_TRUE_OR_ANY_TRUE` rules are AND-ed together. +- `IS_TRUE_OR_ANY_TRUE` rules are OR-ed together inside the same condition. +- A task gate id is resolved as a condition result first. If there is no condition with that id, it falls back to the raw answer for that question id. + +Supported rule types: + +- `IS_TRUE` +- `IS_FALSE` +- `IS_SET` +- `IS_TRUE_OR_NOT_SET` +- `IS_FALSE_OR_NOT_SET` +- `IS_TRUE_OR_ANY_TRUE` + +Supported rule object types: + +- `question` +- `condition` + +## Public APIs + +All `/department/selfserve/*` and `/modules/self-serve/*` routes require an authenticated user session and the listed permissions. + +`/relay/button/press/post` is different: it uses plate-scanner authentication through `authentication::get_plate_scanner()`, which accepts the token from: + +- `Authorization: Bearer ` +- query parameter `token` +- POST body `token` +- JSON body field `token` + +### `/department/selfserve/questions` + +Purpose: manage shared or legacy self-serve questions. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/questions` | none, optional `id`, `department`, `lane`, `product` | `list_department_selfserve_questions`, optional `view_all_department_selfserve_questions` | Returns one question by `id` or a filtered paginated list. | +| `POST /department/selfserve/questions` | `question`, `description` | `add_department_selfserve_questions` | Optional `department`, `lane`, `product`, `condition_id`, `order_priority`. Use `0/0/0` for shared questions. | +| `PUT /department/selfserve/questions` | `id` | `edit_department_selfserve_questions` | Updates any subset of fields. | +| `DELETE /department/selfserve/questions` | `id` | `delete_department_selfserve_questions` | Soft-deletes the question. | + +Typical failures: + +- `400` missing required fields +- `403` user cannot access the target department +- `404` question not found + +### `/department/selfserve/conditions` + +Purpose: manage named condition gates used by tasks and question visibility. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/conditions` | none, optional `id`, `department`, `lane`, `product`, `machine_type_id` | `list_department_selfserve_conditions`, optional `view_all_department_selfserve_conditions` | Lists one or many conditions. | +| `POST /department/selfserve/conditions` | `name`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_conditions` | Optional parent `condition_id` supports nested trees. | +| `PUT /department/selfserve/conditions` | `id` | `update_department_selfserve_conditions` | Can move a condition between machine types or legacy scopes. | +| `DELETE /department/selfserve/conditions` | `id` | `delete_department_selfserve_conditions` | Soft-deletes the condition. | + +Typical failures: + +- `400` missing scope information +- `403` user cannot access the source or target department +- `404` condition not found + +### `/department/selfserve/condition/rules` + +Purpose: define how a condition becomes true or false. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/condition/rules` | none, optional `id`, `condition_id`, `type`, `object_type`, `object_id` | `list_department_selfserve_condition_rules`, optional `view_all_department_selfserve_condition_rules` | Access is checked through the parent condition's department. | +| `POST /department/selfserve/condition/rules` | `condition_id`, `type`, `object_type`, `object_id`, `name`, `description` | `add_department_selfserve_condition_rules` | `object_type` must be `question` or `condition`. | +| `PUT /department/selfserve/condition/rules` | `id` | `update_department_selfserve_condition_rules` | Supports moving the rule to another condition. | +| `DELETE /department/selfserve/condition/rules` | `id` | `delete_department_selfserve_condition_rules` | Soft-deletes the rule. | + +Typical failures: + +- `400` missing fields +- `403` department access denied through the parent condition +- `404` rule or target condition not found + +### `/department/selfserve/tasks` + +Purpose: manage the task list shown after eligibility evaluation. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/tasks` | none, optional `id`, `department`, `lane`, `product`, `condition_id`, `machine_type_id` | `list_department_selfserve_tasks`, optional `view_all_department_selfserve_tasks` | Lists one or many tasks. | +| `POST /department/selfserve/tasks` | `task`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_tasks` | Optional `condition_id`, `order_priority`, `services`, `buttons`, `dynamic_images_vehicle_type`. | +| `PUT /department/selfserve/tasks` | `id` | `edit_department_selfserve_tasks` | Updates any subset of fields. | +| `DELETE /department/selfserve/tasks` | `id` | `delete_department_selfserve_tasks` | Soft-deletes the task. | + +Notes: + +- Route parameter name is `condition_id`. That value is used as the task gate id. +- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`. +- If an active task does not expose `MACHINE`, the relay will not be enabled. + +Typical failures: + +- `400` missing fields or invalid `services` format +- `403` department access denied +- `404` task not found + +### `/department/selfserve/tasks/attachments*` + +Purpose: attach downloadable assets to self-serve tasks. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/tasks/attachments` | `id` | `list_department_selfserve_task_attachments` | Lists attachments for a task. | +| `GET /department/selfserve/tasks/attachments/download` | `task_id`, `attachment_id` | `download_department_selfserve_task_attachments` | Returns a direct download URL. | +| `POST /department/selfserve/tasks/attachments/upload` | `task_id`, `base64_file`, `file_name` | `add_department_selfserve_task_attachments` | Stores an attachment and links it to the task. | +| `DELETE /department/selfserve/tasks/attachments` | `task_id`, `attachment_id` | `delete_department_selfserve_task_attachments` | Removes the linked attachment from the task. | + +Typical failures: + +- `400` missing fields +- `403` department access denied +- `404` task or attachment not found + +### `/department/selfserve/vehicle/conditions` + +Purpose: create and manage the vehicle-specific answers that drive eligibility. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/vehicle/conditions` | none, optional `id`, `department`, `customer_id`, `lane`, `reg`, `question` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | `own_*` access is restricted to the user's customer number. | +| `POST /department/selfserve/vehicle/conditions` | `department`, `lane`, `reg`, `question`, `value` | `add_department_selfserve_vehicle_conditions` or `add_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::synchronizeSession()` and returns both the condition row and `selfserve` summary. | +| `PUT /department/selfserve/vehicle/conditions` | `id` | `update_department_selfserve_vehicle_conditions` or `update_own_department_selfserve_vehicle_conditions` | Re-synchronizes the session after the update. | +| `DELETE /department/selfserve/vehicle/conditions` | `id` | `delete_department_selfserve_vehicle_conditions` or `delete_own_department_selfserve_vehicle_conditions` | Deletes the answer and attempts to re-synchronize the session. | + +Typical failures: + +- `400` missing fields or invalid session +- `403` permission denied, wrong department, or wrong customer ownership +- `404` answer row not found + +### `/department/selfserve/vehicle/allowed` + +Purpose: preview whether self-serve is currently allowed for a vehicle on a lane. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/vehicle/allowed` | `lane_id`, `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::previewVehicleEligibility()` and returns questions, tasks, allowed services, and the current session if one exists. | + +Typical failures: + +- `400` missing `lane_id` or `reg` +- `403` permission denied or wrong vehicle ownership +- `404` lane not found + +### `/department/selfserve/washes/summary` + +Purpose: inspect the summary of a self-serve wash. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/washes/summary` | either `session_id`, or `lane_id` plus `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Returns `session`, `lane`, `machine_type`, `questions`, `tasks`, and `events`. | + +Typical failures: + +- `400` missing identifying parameters +- `403` permission denied or wrong vehicle ownership +- `404` session not found, or no session exists for lane and vehicle + +### `/department/selfserve/machine-types` + +Purpose: manage reusable machine type profiles. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /department/selfserve/machine-types` | none, optional `id` | `list_department_selfserve_machine_types` | Lists one or many machine types. | +| `POST /department/selfserve/machine-types` | `name` | `add_department_selfserve_machine_types` | Optional `description`. | +| `PUT /department/selfserve/machine-types` | `id` | `update_department_selfserve_machine_types` | Updates `name` and/or `description`. | +| `DELETE /department/selfserve/machine-types` | `id` | `delete_department_selfserve_machine_types` | Soft-deletes the machine type. | + +Typical failures: + +- `400` missing `name` or empty update +- `404` machine type not found + +### `/modules/self-serve/lane/*` + +Purpose: operational lane control and relay management. + +| Method | Required params | Permissions | Notes | +| --- | --- | --- | --- | +| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. | +| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. | +| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. | +| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. | +| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. | +| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. | + +STOP flow details: + +- `selfserve_lane_command::STOP` requires lane status `OCCUPIED`. +- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number. +- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane. + +Typical failures: + +- `400` invalid parameters +- `403` missing permission +- `404` lane not found +- `403` from `/relay/machine/enable` if the `MACHINE` service is not currently allowed + +### `/relay/button/press/post` + +Purpose: record the physical machine start trigger. + +| Method | Required params | Authentication | Notes | +| --- | --- | --- | --- | +| `GET /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Supported for devices that can only call GET. | +| `POST /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Same behavior as GET. | + +Lane resolution behavior: + +- If `lane_id` is provided, the route verifies that the lane belongs to the scanner's department. +- If `lane_id` is not provided and the department has exactly one lane, that lane is used automatically. +- If `lane_id` is not provided and the department has multiple lanes, the request fails with `400`. + +Runtime behavior: + +- Calls `selfserve_wash_flow::recordMachineStartWebhook()`. +- Marks the session as machine-started. +- Updates lane status/state to occupied and in-wash if needed. +- Sets registration number, customer number, and wash start timestamp on the lane. + +Typical failures: + +- `403` invalid plate scanner token +- `400` missing `lane_id` in a multi-lane department +- `403` lane does not belong to the scanner department +- `404` no active self-serve wash session for the lane + +## Public Code Interfaces + +### `selfserve_wash_flow` and `selfserve_wash_flow_i` + +Main orchestration class for the self-serve lifecycle. + +Public methods: + +| Method | Use it when | Returns | +| --- | --- | --- | +| `previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null)` | You need a read-only eligibility preview without mutating state. | Snapshot with `questions`, `tasks`, `allowed_services`, `allowed`, and optional current `session`. | +| `synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true)` | Answers changed and you want session state, tasks, events, and relay enable to stay in sync. | Full session summary. | +| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. | +| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. | +| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. | +| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. | + +Key implementation details: + +- Calls `selfserve_schema_bootstrap::ensureTables()` in the constructor, so the session and machine-type tables are created lazily and idempotently at runtime. +- Loads shared questions first, then machine-type conditions and tasks first, then legacy fallback only when needed. +- Persists answer, task, and event snapshots on every sync. +- Enables the Shelly machine relay automatically when the snapshot is eligible and `activateMachine` is `true`. + +### `selfserve_condition_evaluator` and `selfserve_condition_evaluator_i` + +Pure evaluation layer for conditions and task gates. + +Public methods: + +| Method | Use it when | +| --- | --- | +| `evaluate(array $conditions, array $rules, array $answers)` | You need a boolean result map keyed by condition id. | +| `taskGateSatisfied(?int $gateId, array $conditionResults, array $answers)` | You need to decide whether a task should be active. | + +### `selfserve_lane` + +Runtime lane aggregate built from traits. + +Important operational methods used by the module: + +- `execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments)` +- `turnOnRelay(selfserve_lane_relay::MACHINE, ?int $duration = null)` +- `turnOffRelay(selfserve_lane_relay::MACHINE)` +- `forceTurnOnMachineRelay(?int $duration = null)` +- `forceTurnOffMachineRelay()` +- `getLaneStatus()` +- `getLaneState()` +- `setLaneStatus(...)` +- `setLaneState(...)` +- `setWashStartTime(...)` +- `getElapsedWashTime()` +- `setLicensePlate(...)` +- `setCustomerNumber(...)` +- `setLaneCache(...)` +- `invoice()` + +Important enums: + +- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE` +- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED` +- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states +- `selfserve_lane_services`: currently only `MACHINE` + +### Machine Type And Wash Session Objects + +`selfserve_machine_types_o` is a reusable configuration object. It stores the profile name and description and is attached to a lane through `department_lanes.machine_type_id`. + +`selfserve_wash_sessions_o` is the lifecycle record. At behavior level it supports: + +- creating a new session with lane, machine type, customer, vehicle, and metadata +- updating status +- marking relay enabled +- marking machine start triggered +- marking completion with optional `order_id` +- selecting the latest open or latest overall session for a lane and registration number + +## End-To-End Example: Happy Path + +The example below shows the intended production flow with one shared question and one machine-type-specific task that exposes `MACHINE`. + +### 1. Create a machine type + +```bash +curl -X POST "$BASE_URL/department/selfserve/machine-types" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "HighPressureFoam", + "description": "Shared profile for foam cannon lanes" + }' +``` + +### 2. Associate the machine type with the lane + +This is done through the existing department lanes route, not a self-serve-specific route. + +```bash +curl -X PUT "$BASE_URL/department/lanes" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "id": 12, + "machine_type_id": 3 + }' +``` + +### 3. Create a shared question + +```bash +curl -X POST "$BASE_URL/department/selfserve/questions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "department": 0, + "lane": 0, + "product": 0, + "question": "Is the hydraulic lock engaged?", + "description": "Required before machine start", + "order_priority": 10 + }' +``` + +### 4. Create a machine-type-specific condition and rule + +```bash +curl -X POST "$BASE_URL/department/selfserve/conditions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "machine_type_id": 3, + "name": "Machine may start", + "description": "All mandatory safety checks passed" + }' +``` + +```bash +curl -X POST "$BASE_URL/department/selfserve/condition/rules" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "condition_id": 14, + "type": "IS_TRUE", + "object_type": "question", + "object_id": 21, + "name": "Hydraulic lock is engaged", + "description": "Question 21 must be answered true" + }' +``` + +### 5. Create a machine-type-specific task that exposes `MACHINE` + +```bash +curl -X POST "$BASE_URL/department/selfserve/tasks" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "machine_type_id": 3, + "condition_id": 14, + "task": "Press the machine start button", + "description": "The relay is enabled automatically when all answers allow it.", + "order_priority": 10, + "services": ["MACHINE"] + }' +``` + +### 6. Preview eligibility before answering + +```bash +curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \ + -H "Authorization: Bearer $TOKEN" +``` + +Typical result before all answers are present: + +```json +{ + "success": true, + "data": { + "allowed": false, + "all_visible_questions_answered": false, + "machine_available": true, + "questions": [ + { + "id": 21, + "question": "Is the hydraulic lock engaged?", + "answer": null + } + ], + "tasks": [] + } +} +``` + +### 7. Submit the answer + +```bash +curl -X POST "$BASE_URL/department/selfserve/vehicle/conditions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "department": 7, + "lane": 12, + "reg": "AB12345", + "question": 21, + "value": true, + "customer_id": 100234 + }' +``` + +The POST response includes a `selfserve` summary. When the answer makes the vehicle eligible, `synchronizeSession()` will: + +- create or update the session +- snapshot the answer and tasks +- populate lane cache with `allowed_services` +- enable the Shelly relay +- log a `MACHINE_RELAY_ENABLED` event + +### 8. Inspect the summary + +```bash +curl "$BASE_URL/department/selfserve/washes/summary?lane_id=12®=AB12345" \ + -H "Authorization: Bearer $TOKEN" +``` + +Expected highlights: + +- `session.status` becomes `MACHINE_RELAY_ENABLED` +- `questions` contains the answered question +- `tasks` contains the machine start task +- `events` contains both `SESSION_SYNCED` and `MACHINE_RELAY_ENABLED` + +### 9. Record the physical machine start + +```bash +curl -X POST "$BASE_URL/relay/button/press/post?token=$SCANNER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lane_id": 12, + "reg": "AB12345", + "source": "shelly-button" + }' +``` + +Expected highlights: + +- `session.status` becomes `MACHINE_STARTED` +- the lane is `OCCUPIED` +- the lane state is `IN_WASH` +- `machine_start_triggered_at` is set +- the summary includes a `MACHINE_START_TRIGGERED` event + +### 10. Stop the lane and complete billing + +```bash +curl -X POST "$BASE_URL/modules/self-serve/lane/command" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lane_id": 12, + "command": "STOP" + }' +``` + +STOP invoices the elapsed wash time using the configured minute product, writes `order_id` to the session when available, logs `SESSION_COMPLETED`, and resets the lane. + +## End-To-End Example: Machine Not Allowed + +This example shows the failure mode the UI usually needs to handle. + +### Preview shows the blocking reason + +```bash +curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \ + -H "Authorization: Bearer $TOKEN" +``` + +Example response: + +```json +{ + "success": true, + "data": { + "allowed": false, + "all_visible_questions_answered": true, + "machine_available": false, + "allowed_services": [], + "questions": [ + { + "id": 21, + "question": "Is the hydraulic lock engaged?", + "answer": true + } + ], + "tasks": [] + } +} +``` + +Interpretation: + +- All questions are answered. +- The machine is still not allowed because either `relay_machine_id` is missing on the lane or no active task exposed `MACHINE`. + +If an operator tries to enable the relay manually anyway: + +```bash +curl -X POST "$BASE_URL/modules/self-serve/lane/relay/machine/enable" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lane_id": 12 + }' +``` + +The route returns `403` when the lane cache does not currently allow `MACHINE`. + +## PHP Examples + +These examples assume you are running inside the app runtime after the normal bootstrap has loaded the classes and database connection. + +### Preview eligibility + +```php +previewVehicleEligibility(12, 'AB12345', 100234); + +var_dump([ + 'allowed' => $preview['allowed'], + 'all_visible_questions_answered' => $preview['all_visible_questions_answered'], + 'questions' => $preview['questions'], + 'tasks' => $preview['tasks'], +]); +``` + +### Synchronize a session after answers change + +```php +add( + 7, + 12, + 'AB12345', + 21, + true, + 100234 +); + +$flow = new selfserve_wash_flow(); +$summary = $flow->synchronizeSession(12, 'AB12345', 100234); + +var_dump([ + 'session_id' => $summary['session']['id'], + 'status' => $summary['session']['status'], + 'events' => $summary['events'], +]); +``` + +### Record the machine start webhook + +```php +recordMachineStartWebhook(12, 'AB12345', [ + 'source' => 'manual-test', + 'button' => 'start', +]); + +var_dump([ + 'status' => $summary['session']['status'], + 'machine_start_triggered' => $summary['session']['machine_start_triggered'], + 'events' => $summary['events'], +]); +``` + +### Retrieve the latest wash summary + +```php +getLatestSessionSummary(12, 'AB12345'); + +var_dump([ + 'session' => $summary['session'], + 'questions' => $summary['questions'], + 'tasks' => $summary['tasks'], + 'events' => $summary['events'], +]); +``` + +### Define a machine type and associate it with a lane + +```php +add( + 'HighPressureFoam', + 'Reusable profile for foam cannon lanes' +); + +$lane = (new department_lanes_o())->select(12); +$lane->machine_type_id->set((int)$machineType->id); + +var_dump([ + 'machine_type_id' => $machineType->id, + 'lane_id' => $lane->id, +]); +``` + +### Add a shared question and machine-type-specific conditions, rules, and tasks + +```php +add( + 0, + 0, + 0, + 'Is the hydraulic lock engaged?', + 'Required before machine start', + null, + 10 +); + +$condition = (new department_selfserve_conditions_o())->add( + 0, + 0, + 0, + 'Machine may start', + 'All mandatory safety checks passed', + null, + 3 +); + +(new department_selfserve_condition_rules_o())->add( + (int)$condition->id, + selfserve_condition_rule_type::IS_TRUE->value, + selfserve_condition_rule_object_type::QUESTION->value, + (int)$question->id, + 'Hydraulic lock must be engaged', + 'The machine may only start when the shared question is true' +); + +$task = (new department_selfserve_tasks_o())->add( + 0, + 0, + 0, + (int)$condition->id, + 'Press the machine start button', + 'The relay is already enabled when this task becomes active', + 10, + [selfserve_lane_services::MACHINE], + null, + null, + 3 +); + +var_dump([ + 'question_id' => $question->id, + 'condition_id' => $condition->id, + 'task_id' => $task->id, +]); +``` + +## Implementation Recipes + +### Add a new self-serve machine type + +1. Create a `selfserve_machine_types` row through `/department/selfserve/machine-types` or `selfserve_machine_types_o`. +2. Set `department_lanes.machine_type_id` through `/department/lanes` or `department_lanes_o`. +3. Add the machine-type-specific conditions. +4. Add the condition rules that evaluate your questions or nested conditions. +5. Add the machine-type-specific tasks. +6. Ensure at least one active task exposes `MACHINE` if the lane should auto-enable the relay. + +### Configure shared questions + +Use `/department/selfserve/questions` with: + +- `department = 0` +- `lane = 0` +- `product = 0` + +Once shared questions exist, `selfserve_wash_flow` prefers them over legacy lane/product question rows. + +### Add machine-type-specific conditions, rules, and tasks + +Recommended pattern: + +1. Keep questions shared. +2. Define one or more conditions per machine type. +3. Attach rules that reference shared questions or nested conditions. +4. Gate tasks with `condition_id`. +5. Put `MACHINE` on the task that should allow relay enable. + +### Understand webhook and STOP interaction + +- `synchronizeSession()` can enable the machine relay before the physical machine has started. +- `/relay/button/press/post` is the authoritative machine-start signal. That is the point where the session becomes `MACHINE_STARTED` and the lane wash timer is initialized if needed. +- `STOP` is the point where billing is finalized. +- `selfserve_lane_invoice_t::invoice()` bills `ceil(elapsedWashTime / 60)` units of the configured minute product. +- The current implementation creates the order with system user id `2285`. +- `completeLatestSessionForLane()` stores the final `order_id` on the session when STOP can provide it. + +### Billing prerequisites + +Billing on STOP depends on: + +- self-serve minute product config being set +- lane status being `OCCUPIED` +- lane customer number being set +- lane license plate being set +- a positive elapsed wash time + +Relevant config: + +- `selfserve.enabled` +- `selfserve.minute_product` +- department variable `selfserve_enabled` + +## Troubleshooting + +### `allowed` is always `false` + +Check all three eligibility requirements: + +1. Every visible question must have an answer. +2. The lane must have `relay_machine_id`. +3. At least one active task must expose `MACHINE`. + +Also check whether the machine-type-specific tasks and conditions exist for the lane's `machine_type_id`. If they do not, the flow may fall back to legacy lane/product data instead. + +### The relay is not enabled after an answer update + +Common causes: + +- `synchronizeSession()` was called with `activateMachine = false` +- the active task list does not expose `MACHINE` +- the lane is `CLOSED`, `MAINTENANCE`, or `FAULT` +- `relay_machine_id` is missing on the lane + +Inspect the latest summary and look for: + +- `allowed_services` +- `session.machine_relay_enabled` +- a `MACHINE_RELAY_ENABLED` event + +### The machine button webhook returns `404` + +This means the route could not find an active self-serve session for the resolved lane. + +Check: + +- the lane id resolved from the scanner department is correct +- the vehicle registration matches the session registration +- the session was synchronized before the button press + +If you pass `reg` and no open session exists yet, `recordMachineStartWebhook()` will attempt a non-activating synchronize first. If that still cannot resolve a session, inspect the lane and answer data. + +### The summary does not show questions, tasks, or events you expected + +Check: + +- whether you are reading by `session_id` or by `lane_id` plus `reg` +- whether a newer session exists for the same lane and vehicle +- whether the lane changed machine type after the session was created +- whether the question or task was visible at the moment the session was synchronized + +Remember that session answers and tasks are snapshots, not live joins. + +### The webhook fails in a multi-lane department + +If the plate scanner belongs to a department with more than one lane, you must include `lane_id` in the webhook request. + +### STOP did not create an `order_id` on the session + +Check: + +- the minute product configuration +- lane customer number and registration number +- that wash time was greater than zero +- whether `invoice()` threw before completion + +The STOP flow tries not to let relay or session-completion errors block the lane reset. If billing failed earlier, the lane may still reset without a final `order_id`. + +## Operational Notes + +- Runtime schema changes are additive and lazy through `classes/selfserve_schema_bootstrap.php`. +- The session tables are safe to create idempotently from runtime flows because the project does not use a centralized migration runner. +- Machine relay control is delegated to the Shelly module through `selfserve_lane_relay_controller_t`. +- Manual relay enable is still gated by the lane cache, while force enable and force disable bypass that gate. + +## Suggested Usage Pattern + +For new implementations, the intended setup is: + +1. Define one or more reusable machine types. +2. Attach each self-serve lane to a machine type. +3. Keep questions shared. +4. Put machine-specific logic in machine-type conditions, rules, and tasks. +5. Let vehicle answer changes call `synchronizeSession()`. +6. Let the physical button or PLC call `/relay/button/press/post`. +7. Let the normal STOP lane command complete billing and close the session.