From d890d589ff80bdd8461217b19bd73dd82316bd0d Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 16 Mar 2026 11:36:48 +0100 Subject: [PATCH] Add reusable routes for department self-serve machine types, n8n workflows, and executions, with corresponding unit tests and service integration. --- .github/workflows/copilot.yml | 17 + docker-compose.example.yml | 204 ++++-- openapi.yaml | 549 +++++++++++++++- .../economic_v2_distribution_service.php | 478 ++++++++++---- services/nginx/app/classes/n8n.php | 587 +++++++++++++++++ .../classes/selfserve_schema_bootstrap.php | 151 +++++ services/nginx/app/interfaces/n8n_i.php | 28 + .../app/modules/n8n/config/n8n_api_key_c.php | 29 + .../app/modules/n8n/config/n8n_api_url_c.php | 29 + .../app/modules/n8n/config/n8n_enabled_c.php | 29 + .../n8n/config/n8n_webhook_base_url_c.php | 29 + services/nginx/app/modules/n8n/n8n_c.php | 40 ++ .../classes/selfserve_condition_evaluator.php | 134 ++++ .../selfserve/classes/selfserve_wash_flow.php | 612 ++++++++++++++++++ .../selfserve_condition_rule_object_type.php | 9 + .../helpers/selfserve_condition_rule_type.php | 13 + .../helpers/selfserve_wash_event_type.php | 11 + .../helpers/selfserve_wash_session_status.php | 13 + .../selfserve_condition_evaluator_i.php | 22 + .../interfaces/selfserve_wash_flow_i.php | 18 + .../traits/selfserve_lane_command_t.php | 14 +- .../traits/selfserve_lane_invoice_t.php | 10 +- .../nginx/app/objects/department_lanes_o.php | 16 +- .../department_selfserve_conditions_o.php | 29 +- .../department_selfserve_questions_o.php | 20 + .../objects/department_selfserve_tasks_o.php | 29 +- ...artment_selfserve_vehicle_conditions_o.php | 19 +- .../app/objects/selfserve_machine_types_o.php | 67 ++ .../selfserve_wash_session_answers_o.php | 97 +++ .../selfserve_wash_session_events_o.php | 58 ++ .../selfserve_wash_session_tasks_o.php | 83 +++ .../app/objects/selfserve_wash_sessions_o.php | 193 ++++++ .../nginx/app/routes/departmentLanesRoute.php | 25 +- ...departmentSelfserveConditionRulesRoute.php | 17 +- .../departmentSelfserveConditionsRoute.php | 56 +- .../departmentSelfserveMachineTypesRoute.php | 125 ++++ .../departmentSelfserveQuestionsRoute.php | 43 +- .../routes/departmentSelfserveTasksRoute.php | 73 ++- ...artmentSelfserveVehicleConditionsRoute.php | 237 +++++-- .../app/routes/machineButtonPressRoute.php | 83 ++- .../nginx/app/routes/moduleConfigRoute.php | 40 ++ services/nginx/app/routes/moduleN8nRoute.php | 351 ++++++++++ ...nomicV2DistributionServiceFallbackTest.php | 263 ++++++++ .../tests/Unit/N8n/N8nRouteHelpersTest.php | 46 ++ .../app/tests/Unit/N8n/N8nRouteWiringTest.php | 27 + .../SelfserveConditionEvaluatorTest.php | 69 ++ .../Selfserve/SelfserveOpenApiSpecTest.php | 43 ++ .../Selfserve/SelfserveRouteWiringTest.php | 33 + 48 files changed, 4812 insertions(+), 356 deletions(-) create mode 100644 .github/workflows/copilot.yml create mode 100644 services/nginx/app/classes/n8n.php create mode 100644 services/nginx/app/classes/selfserve_schema_bootstrap.php create mode 100644 services/nginx/app/interfaces/n8n_i.php create mode 100644 services/nginx/app/modules/n8n/config/n8n_api_key_c.php create mode 100644 services/nginx/app/modules/n8n/config/n8n_api_url_c.php create mode 100644 services/nginx/app/modules/n8n/config/n8n_enabled_c.php create mode 100644 services/nginx/app/modules/n8n/config/n8n_webhook_base_url_c.php create mode 100644 services/nginx/app/modules/n8n/n8n_c.php create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php create mode 100644 services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php create mode 100644 services/nginx/app/modules/selfserve/helpers/selfserve_condition_rule_object_type.php create mode 100644 services/nginx/app/modules/selfserve/helpers/selfserve_condition_rule_type.php create mode 100644 services/nginx/app/modules/selfserve/helpers/selfserve_wash_event_type.php create mode 100644 services/nginx/app/modules/selfserve/helpers/selfserve_wash_session_status.php create mode 100644 services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php create mode 100644 services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php create mode 100644 services/nginx/app/objects/selfserve_machine_types_o.php create mode 100644 services/nginx/app/objects/selfserve_wash_session_answers_o.php create mode 100644 services/nginx/app/objects/selfserve_wash_session_events_o.php create mode 100644 services/nginx/app/objects/selfserve_wash_session_tasks_o.php create mode 100644 services/nginx/app/objects/selfserve_wash_sessions_o.php create mode 100644 services/nginx/app/routes/departmentSelfserveMachineTypesRoute.php create mode 100644 services/nginx/app/routes/moduleN8nRoute.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php create mode 100644 services/nginx/app/tests/Unit/N8n/N8nRouteHelpersTest.php create mode 100644 services/nginx/app/tests/Unit/N8n/N8nRouteWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php diff --git a/.github/workflows/copilot.yml b/.github/workflows/copilot.yml new file mode 100644 index 00000000..7478bc4f --- /dev/null +++ b/.github/workflows/copilot.yml @@ -0,0 +1,17 @@ +name: Copilot Task Dispatcher +on: + workflow_dispatch: + inputs: + task: + description: 'The task description for Copilot' + required: true + type: string + +jobs: + assign-task: + runs-on: ubuntu-latest + steps: + - name: Assign task to Copilot + run: | + echo "Assigning task: ${{ github.event.inputs.task }}" + echo "Task requested by: ${{ github.actor }}" diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 7789d95b..3a0c477c 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,73 +1,143 @@ -version: '3.9' - services: - app: - build: - context: . - dockerfile: Dockerfile - container_name: php_app - ports: - - "8080:80" # Map port 80 in the container to port 8080 on the host - volumes: - - .:/var/www/html # Mount the current directory to the container - - ./logs:/var/log/apache2 # Mount logs to be accessible on the host - environment: - # Pass environment variables for the application - DEBUG: true - USE_ENV: true - CONFIG_TIMEZONE: "Europe/Copenhagen" # Set the timezone - # Database configuration - CONFIG_DB_HOST: db - CONFIG_DB_USER: root - CONFIG_DB_PASSWORD: example_password - CONFIG_DB_DATABASE: example_db - # Security configuration - ENCRYPTION_KEY: "" # Set the encryption key - CORS: "*" - # Economic API credentials - ECONOMIC_API_APP_ACCESS_GRANT: "" # Economic API access grant token (1) - ECONOMIC_API_APP_ACCESS_GRANT2: "" # Economic API access grant token (2) - ECONOMIC_API_APP_SECRET_TOKEN: "" # Economic API secret token - # WordPress configuration - WORDPRESS_API_URL: "" # WordPress API URL (e.g. https://www.example.com/wp-admin/admin-ajax.php) - WORDPRESS_STATIC_TOKEN: "" # WordPress static token for authentication - EMAIL_WASH_CERTIFICATE_TOKEN: "" # Email wash certificate token - # MINIO configuration - MINIO_ENDPOINT: "" # Minio endpoint (e.g. http://localhost:9000) - MINIO_ACCESS_KEY: "" # Minio access key - MINIO_SECRET_KEY: "" # Minio secret key - # Redis configuration - REDIS_CONFIG_HOST: redis - REDIS_CONFIG_DATABASE: 0 - REDIS_CONFIG_PASSWORD: "" + traefik: + image: traefik:2.11 + container_name: traefik + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + - ./services/traefik/acme.json:/acme.json + labels: + - "traefik.enable=true" + # Dashboard configuration (replace with your domain) + - "traefik.http.routers.traefik.rule=Host(`traefik.example.com`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls=true" + - "traefik.http.routers.traefik.tls.certresolver=le" + - "traefik.http.routers.traefik.service=api@internal" - # Slack configuration - SLACK_DEFAULT_WEBHOOK: "" # Slack default webhook URL - depends_on: - - db - - redis + redis: + image: redis:7 + container_name: redis + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 - db: - image: mysql:8.0 - container_name: mysql - ports: - - "3306:3306" # Map MySQL container's port 3306 to the host - volumes: - - db_data:/var/lib/mysql # Persist database data - environment: - MYSQL_ROOT_PASSWORD: example_password - MYSQL_DATABASE: example_db - MYSQL_USER: app_user - MYSQL_PASSWORD: app_password + mysql: + image: mysql:8.4 + container_name: mysql + environment: + MYSQL_ROOT_PASSWORD: ${CONFIG_DB_PASSWORD:-example_root_password} + MYSQL_DATABASE: ${CONFIG_DB_DATABASE:-example_db} + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin -u root ping --silent"] + interval: 10s + timeout: 5s + retries: 10 - redis: - image: redis:6.2 - container_name: redis - ports: - - "6379:6379" # Map Redis container's port 6379 to the host - volumes: - - redis_data:/data # Persist Redis data + caddy: + image: caddy:2.7.6-alpine + container_name: caddy + depends_on: + - php1 + volumes: + - ./services/nginx/app:/var/www/html + - ./services/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./services/caddy/logs:/var/log/caddy + labels: + - "traefik.enable=true" + # API routing (replace with your domain) + - "traefik.http.routers.api.rule=Host(`api.example.com`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls=true" + - "traefik.http.routers.api.tls.certresolver=le" + - "traefik.http.routers.api.service=caddy" + # HTTP to HTTPS redirect + - "traefik.http.routers.api-http.rule=Host(`api.example.com`)" + - "traefik.http.routers.api-http.entrypoints=web" + - "traefik.http.routers.api-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.api-http.service=caddy" + # Local development (HTTP only) + - "traefik.http.routers.local.rule=Host(`localhost`)" + - "traefik.http.routers.local.entrypoints=web" + - "traefik.http.routers.local.service=caddy" + + php1: + build: + context: . + dockerfile: services/php/Dockerfile + container_name: php1 + depends_on: + - redis + - mysql + command: ["php-fpm"] + env_file: + - .env.example + environment: + AUTO_COMPOSER_INSTALL: "true" + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + - ./services/php/logs:/var/log/php + + php-cron: + build: + context: . + dockerfile: services/php/Dockerfile + container_name: php-cron + depends_on: + - redis + - mysql + command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"] + env_file: + - .env.example + environment: + AUTO_COMPOSER_INSTALL: "false" + volumes: + - ./services/nginx/app:/var/www/html + - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro + + n8n: + image: n8nio/n8n:latest + container_name: n8n + restart: always + environment: + - N8N_HOST=n8n.example.com + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - NODE_ENV=production + - WEBHOOK_URL=https://n8n.example.com/ + - GENERIC_TIMEZONE=${CONFIG_TIMEZONE:-Europe/Copenhagen} + volumes: + - n8n_data:/home/node/.n8n + labels: + - "traefik.enable=true" + # n8n over HTTPS (replace with your domain and cert resolver) + - "traefik.http.routers.n8n.rule=Host(`n8n.example.com`)" + - "traefik.http.routers.n8n.entrypoints=websecure" + - "traefik.http.routers.n8n.tls=true" + - "traefik.http.routers.n8n.tls.certresolver=le" + - "traefik.http.routers.n8n.service=n8n" + # n8n HTTP to HTTPS redirect + - "traefik.http.routers.n8n-http.rule=Host(`n8n.example.com`)" + - "traefik.http.routers.n8n-http.entrypoints=web" + - "traefik.http.routers.n8n-http.middlewares=redirect-to-https@file" + - "traefik.http.routers.n8n-http.service=n8n" + # n8n service port + - "traefik.http.services.n8n.loadbalancer.server.port=5678" volumes: - db_data: # Persistent data for MySQL - redis_data: # Persistent data for Redis \ No newline at end of file + mysql_data: + redis_data: + n8n_data: \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml index 8d7c4248..49772f82 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3257,6 +3257,113 @@ paths: items: $ref: '#/components/schemas/DepartmentGuest' + /department/selfserve/machine-types: + get: + tags: + - Self-Serve + summary: List reusable self-serve machine types + operationId: listSelfserveMachineTypes + parameters: + - name: id + in: query + required: false + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Successfully retrieved machine types + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SelfserveMachineType' + - type: array + items: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Self-Serve + summary: Add reusable self-serve machine type + operationId: addSelfserveMachineType + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully added machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + put: + tags: + - Self-Serve + summary: Update reusable self-serve machine type + operationId: updateSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully updated machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Self-Serve + summary: Delete reusable self-serve machine type + operationId: deleteSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Successfully deleted machine type + content: + application/json: + schema: + type: string + example: Machine type deleted + '404': + $ref: '#/components/responses/NotFound' + /department/selfserve/questions: get: tags: @@ -3307,7 +3414,7 @@ paths: tags: - Self-Serve summary: Add self-serve question - description: Add a new self-serve question. + description: Add a new self-serve question. Questions are typically shared across departments and lanes by omitting department, lane, and product, which default to 0. operationId: addSelfserveQuestion requestBody: required: true @@ -3316,18 +3423,18 @@ paths: schema: type: object required: - - department - - lane - - product - question - description properties: department: type: integer + default: 0 lane: type: integer + default: 0 product: type: integer + default: 0 question: type: string description: @@ -3455,6 +3562,16 @@ paths: description: Filter by condition ID schema: type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer - $ref: '#/components/parameters/PageParam' - $ref: '#/components/parameters/PerPageParam' - $ref: '#/components/parameters/SearchParam' @@ -3477,7 +3594,7 @@ paths: tags: - Self-Serve summary: Add self-serve condition - description: Add a new self-serve condition. + description: Add a new self-serve condition. Either provide a reusable machine_type_id or a legacy department/lane/product scope. operationId: addSelfserveCondition requestBody: required: true @@ -3486,18 +3603,21 @@ paths: schema: type: object required: - - department - - lane - - product - name - description properties: department: type: integer + default: 0 lane: type: integer + default: 0 product: type: integer + default: 0 + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -3542,6 +3662,9 @@ paths: type: integer product: type: integer + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -3848,7 +3971,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' '400': $ref: '#/components/responses/BadRequest' '500': @@ -3892,7 +4015,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' '400': $ref: '#/components/responses/BadRequest' '404': @@ -3919,13 +4042,83 @@ paths: content: application/json: schema: - type: string - example: Condition deleted + type: object + properties: + message: + type: string + example: Condition deleted + selfserve: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true '400': $ref: '#/components/responses/BadRequest' '404': $ref: '#/components/responses/NotFound' + /department/selfserve/vehicle/allowed: + get: + tags: + - Self-Serve + summary: Check whether self-serve is allowed for a vehicle on a lane + operationId: getSelfserveVehicleAllowed + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + - name: reg + in: query + required: true + schema: + type: string + responses: + '200': + description: Successfully evaluated self-serve eligibility + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/washes/summary: + get: + tags: + - Self-Serve + summary: Get self-serve wash summary + operationId: getSelfserveWashSummary + parameters: + - name: session_id + in: query + required: false + schema: + type: integer + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string + responses: + '200': + description: Successfully retrieved self-serve wash summary + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /department/selfserve/tasks: get: tags: @@ -3981,7 +4174,7 @@ paths: tags: - Self-Serve summary: Add self-serve task - description: Add a new self-serve task. + description: Add a new self-serve task. Either provide a reusable machine_type_id or a legacy department/lane/product scope. operationId: addSelfserveTask requestBody: required: true @@ -3990,18 +4183,21 @@ paths: schema: type: object required: - - department - - lane - - product - task - description properties: department: type: integer + default: 0 lane: type: integer + default: 0 product: type: integer + default: 0 + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -4064,6 +4260,9 @@ paths: type: integer product: type: integer + machine_type_id: + type: integer + nullable: true condition_id: type: integer nullable: true @@ -6578,19 +6777,59 @@ paths: get: tags: - Plate Scans - summary: Add button press + summary: Record machine start button press webhook operationId: addButtonPress parameters: - name: token in: query - required: true + required: false schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string responses: '201': - description: Button press recorded + description: Button press recorded and linked to a self-serve wash session content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Plate Scans + summary: Record machine start button press webhook + operationId: addButtonPressPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + reg: + type: string + responses: + '201': + description: Button press recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' # Module - e-conomic Endpoints /economic/customers/import: @@ -11322,6 +11561,257 @@ components: enum: - MACHINE + SelfserveMachineType: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveVisibleQuestion: + type: object + properties: + id: + type: integer + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + answer: + type: boolean + nullable: true + + SelfserveTaskDecision: + type: object + properties: + id: + type: integer + task: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashSession: + type: object + properties: + id: + type: integer + lane_id: + type: integer + department_id: + type: integer + machine_type_id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + reg: + type: string + status: + type: string + allowed: + type: boolean + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + format: date-time + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + format: date-time + nullable: true + order_id: + type: integer + nullable: true + completed_at: + type: string + format: date-time + nullable: true + metadata: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveWashQuestionAnswer: + type: object + properties: + question_id: + type: integer + question: + type: string + answer: + type: boolean + answered_at: + type: string + format: date-time + + SelfserveWashTaskSnapshot: + type: object + properties: + task_id: + type: integer + nullable: true + task: + type: string + description: + type: string + nullable: true + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashEvent: + type: object + properties: + id: + type: integer + type: + type: string + payload: + type: object + additionalProperties: true + nullable: true + created_at: + type: string + format: date-time + + SelfserveVehicleAllowedResponse: + type: object + properties: + lane: + $ref: '#/components/schemas/DepartmentLane' + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + vehicle: + type: object + additionalProperties: true + nullable: true + reg: + type: string + customer_number: + type: integer + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveVisibleQuestion' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveTaskDecision' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + nullable: true + + SelfserveWashSummary: + type: object + properties: + session: + $ref: '#/components/schemas/SelfserveWashSession' + lane: + allOf: + - $ref: '#/components/schemas/DepartmentLane' + nullable: true + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveWashQuestionAnswer' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + events: + type: array + items: + $ref: '#/components/schemas/SelfserveWashEvent' + + DepartmentSelfserveVehicleConditionMutationResponse: + type: object + properties: + condition: + $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + + MachineButtonPressWebhookResponse: + type: object + properties: + message: + type: string + scanner: + type: string + lane_id: + type: integer + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + DepartmentSelfserveQuestion: type: object properties: @@ -11372,6 +11862,10 @@ components: product: type: integer description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true condition_id: type: integer description: Condition ID (if conditional task) @@ -11423,6 +11917,10 @@ components: product: type: integer description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true condition_id: type: integer description: Optional condition ID @@ -11722,6 +12220,11 @@ components: type: integer nullable: true minimum: 1 + machine_type_id: + type: integer + nullable: true + status: + type: string created_at: type: string format: date-time @@ -11749,6 +12252,9 @@ components: type: integer nullable: true minimum: 1 + machine_type_id: + type: integer + nullable: true DepartmentLaneUpdate: type: object @@ -11771,6 +12277,9 @@ components: type: integer nullable: true minimum: 1 + machine_type_id: + type: integer + nullable: true DepartmentGate: type: object diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php index 01ffd088..bed5ab62 100644 --- a/services/nginx/app/classes/economic_v2_distribution_service.php +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -13,6 +13,10 @@ use objects\users_o; class economic_v2_distribution_service { + private const SYSTEM_ORDER_DEPARTMENT_ID = 10; + private const FIXED_PRICING_SYSTEM_ORDER_REFERENCE = 'Fast pris aftale'; + private const WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE = 'Vaskeabonnementer'; + private economic_v2_versioning_service $versioning; private array $department_name_cache = []; private array $department_excluded_cache = []; @@ -23,14 +27,22 @@ class economic_v2_distribution_service private array $orders_in_range_cache = []; private array $order_items_by_order_ids_cache = []; private array $vehicle_subscription_versions_in_range_cache = []; + private array $version_table_has_rows_cache = []; + private bool $best_effort_backfill_attempted = false; - public function __construct() + public function __construct(?economic_v2_versioning_service $versioning = null) { - $this->versioning = new economic_v2_versioning_service(); + $this->versioning = $versioning ?? new economic_v2_versioning_service(); } public function getAllDistributions(string $date_from, string $date_to): array { + $this->ensureVersionHistoryAvailable([ + 'fixed_pricing', + 'vehicle_subscriptions', + 'discount_overrides', + ]); + return [ 'fixed_pricing' => $this->getFixedPricingDistribution($date_from, $date_to), 'wash_subscriptions' => $this->getWashSubscriptionsDistribution($date_from, $date_to), @@ -40,67 +52,24 @@ class economic_v2_distribution_service public function getFixedPricingDistribution(string $date_from, string $date_to): array { + $this->ensureVersionHistoryAvailable(['fixed_pricing']); + [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); $orders = $this->fetchOrdersInRange($from_ts, $to_ts); $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); - - $groups = []; - $customer_transactions = []; - $warnings = []; - - foreach ($orders as $order) { - $order_id = (int)$order['id']; - $customer_number = (int)$order['customer_id']; - $department_id = (int)$order['department_id']; - $created_at = (string)$order['created_at']; - - if (!$this->isDepartmentEligible($department_id)) { - continue; - } - - $fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at); - if ($fixed_version === null) { - continue; - } - - $month_key = substr($created_at, 0, 7); - $group_key = $customer_number . '|' . (int)$fixed_version['id'] . '|' . $month_key; - if (!isset($groups[$group_key])) { - $groups[$group_key] = [ - 'customer_number' => $customer_number, - 'version_id' => (int)$fixed_version['id'], - 'month' => $month_key, - 'price' => (float)($fixed_version['price'] ?? 0), - 'description' => (string)($fixed_version['description'] ?? ''), - 'source' => (string)($fixed_version['source'] ?? 'unknown'), - 'confidence' => (float)($fixed_version['confidence'] ?? 0), - 'inferred' => (bool)($fixed_version['inferred'] ?? false), - 'effective_from' => (string)($fixed_version['effective_from'] ?? ''), - 'effective_to' => $fixed_version['effective_to'] ?? null, - 'original_price' => 0.0, - 'department_totals' => [], - 'order_ids' => [], - ]; - } - - $order_original_price = $this->calculateOrderOriginalPrice( - $order_items[$order_id] ?? [], - $customer_number, - $department_id, - $created_at - ); - $groups[$group_key]['original_price'] += $order_original_price; - if (!isset($groups[$group_key]['department_totals'][$department_id])) { - $groups[$group_key]['department_totals'][$department_id] = 0.0; - } - $groups[$group_key]['department_totals'][$department_id] += $order_original_price; - $groups[$group_key]['order_ids'][] = $order_id; - - if (!isset($customer_transactions[$customer_number][$order_id])) { - $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id); + $collected = $this->collectFixedPricingData($orders, $order_items); + if (empty($collected['groups'])) { + $fallback = $this->collectFixedPricingData($orders, $order_items, true); + if (!empty($fallback['groups'])) { + $fallback['warnings'][] = 'System order fallback used for fixed pricing (department 10).'; + $collected = $fallback; } } + $groups = $collected['groups']; + $customer_transactions = $collected['customer_transactions']; + $warnings = $collected['warnings']; + $customers = []; $collective = [ 'total_fixed_price' => 0.0, @@ -206,83 +175,26 @@ class economic_v2_distribution_service public function getWashSubscriptionsDistribution(string $date_from, string $date_to): array { + $this->ensureVersionHistoryAvailable(['vehicle_subscriptions']); + [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); $orders = $this->fetchOrdersInRange($from_ts, $to_ts); + $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); $months = $this->listMonthKeys($from_ts, $to_ts); - - $groups = []; - $customer_transactions = []; - $customer_department_month_map = []; - $warnings = []; - - foreach ($orders as $order) { - $order_id = (int)$order['id']; - $customer_number = (int)$order['customer_id']; - $department_id = (int)$order['department_id']; - $created_at = (string)$order['created_at']; - $reg = trim((string)($order['reg_1'] ?? '')); - - if (!$this->isDepartmentEligible($department_id)) { - continue; - } - - $month_key = substr($created_at, 0, 7); - if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) { - $customer_department_month_map[$customer_number][$month_key][$department_id] = 0; - } - $customer_department_month_map[$customer_number][$month_key][$department_id]++; - - if ($reg === '') { - continue; - } - - $active_subscriptions = $this->versioning->resolveVehicleSubscriptionVersionsAt($customer_number, $created_at); - $matching_version = null; - foreach ($active_subscriptions as $candidate) { - if (strcasecmp((string)$candidate['reg'], $reg) === 0) { - $matching_version = $candidate; - break; - } - } - if ($matching_version === null) { - continue; - } - - $monthly_price = $this->getSubscriptionMonthlyPrice((int)$matching_version['vehicle_type']); - if ($monthly_price <= 0.0) { - $warnings[] = 'Subscription type ' . (int)$matching_version['vehicle_type'] . ' has no monthly price for customer ' . $customer_number; - continue; - } - - $group_key = $customer_number . '|' . (string)$matching_version['reg'] . '|' . (int)$matching_version['id'] . '|' . $month_key; - if (!isset($groups[$group_key])) { - $groups[$group_key] = [ - 'customer_number' => $customer_number, - 'reg' => (string)$matching_version['reg'], - 'vehicle_type' => (int)$matching_version['vehicle_type'], - 'version_id' => (int)$matching_version['id'], - 'month' => $month_key, - 'monthly_price' => $monthly_price, - 'source' => (string)($matching_version['source'] ?? 'unknown'), - 'confidence' => (float)($matching_version['confidence'] ?? 0), - 'inferred' => (bool)($matching_version['inferred'] ?? false), - 'distribution' => [], - 'order_ids' => [], - 'fallback' => false, - ]; - } - - if (!isset($groups[$group_key]['distribution'][$department_id])) { - $groups[$group_key]['distribution'][$department_id] = 0; - } - $groups[$group_key]['distribution'][$department_id]++; - $groups[$group_key]['order_ids'][] = $order_id; - - if (!isset($customer_transactions[$customer_number][$order_id])) { - $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id); + $collected = $this->collectWashSubscriptionData($orders, $order_items); + if (empty($collected['groups'])) { + $fallback = $this->collectWashSubscriptionData($orders, $order_items, true); + if (!empty($fallback['groups'])) { + $fallback['warnings'][] = 'System order fallback used for wash subscriptions (department 10).'; + $collected = $fallback; } } + $groups = $collected['groups']; + $customer_transactions = $collected['customer_transactions']; + $customer_department_month_map = $collected['customer_department_month_map']; + $warnings = $collected['warnings']; + $version_rows = $this->fetchVehicleSubscriptionVersionRows($from_ts, $to_ts); foreach ($version_rows as $row) { $customer_number = (int)$row['customer_number']; @@ -401,6 +313,8 @@ class economic_v2_distribution_service public function getCustomerPricesDistribution(string $date_from, string $date_to): array { + $this->ensureVersionHistoryAvailable(['discount_overrides']); + [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); $orders = $this->fetchOrdersInRange($from_ts, $to_ts); $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); @@ -487,14 +401,230 @@ class economic_v2_distribution_service ]; } - private function buildDateRange(string $date_from, string $date_to): array + protected function ensureVersionHistoryAvailable(array $areas): void + { + if ($this->best_effort_backfill_attempted) { + return; + } + + foreach ($areas as $area) { + $table = $this->getVersionTableForArea($area); + if ($table === null || $this->versionTableHasRows($table)) { + continue; + } + + $this->best_effort_backfill_attempted = true; + $this->versioning->runBestEffortBackfill(); + $this->version_table_has_rows_cache = []; + return; + } + } + + protected function getVersionTableForArea(string $area): ?string + { + return match ($area) { + 'fixed_pricing' => 'customer_fixed_pricing_versions', + 'vehicle_subscriptions' => 'customer_vehicle_subscription_versions', + 'discount_overrides' => 'customer_discount_override_versions', + default => null, + }; + } + + protected function versionTableHasRows(string $table): bool + { + if (array_key_exists($table, $this->version_table_has_rows_cache)) { + return $this->version_table_has_rows_cache[$table]; + } + + global $db; + $allowed_tables = [ + 'customer_fixed_pricing_versions' => true, + 'customer_vehicle_subscription_versions' => true, + 'customer_discount_override_versions' => true, + ]; + if (!isset($allowed_tables[$table])) { + return $this->version_table_has_rows_cache[$table] = true; + } + + $result = $db->query("SELECT 1 FROM $table LIMIT 1"); + if (!$result) { + return $this->version_table_has_rows_cache[$table] = false; + } + + return $this->version_table_has_rows_cache[$table] = $result->num_rows > 0; + } + + protected function collectFixedPricingData(array $orders, array $order_items_by_order_id, bool $system_order_fallback = false): array + { + $groups = []; + $customer_transactions = []; + $warnings = []; + + foreach ($orders as $order) { + $order_id = (int)$order['id']; + $customer_number = (int)$order['customer_id']; + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + + if ($system_order_fallback) { + if (!$this->isSystemOrderCandidate($order, self::FIXED_PRICING_SYSTEM_ORDER_REFERENCE)) { + continue; + } + } elseif (!$this->isDepartmentEligible($department_id)) { + continue; + } + + $fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at); + if ($fixed_version === null) { + continue; + } + + $month_key = substr($created_at, 0, 7); + $group_key = $customer_number . '|' . (int)$fixed_version['id'] . '|' . $month_key; + if (!isset($groups[$group_key])) { + $groups[$group_key] = [ + 'customer_number' => $customer_number, + 'version_id' => (int)$fixed_version['id'], + 'month' => $month_key, + 'price' => (float)($fixed_version['price'] ?? 0), + 'description' => (string)($fixed_version['description'] ?? ''), + 'source' => (string)($fixed_version['source'] ?? 'unknown'), + 'confidence' => (float)($fixed_version['confidence'] ?? 0), + 'inferred' => (bool)($fixed_version['inferred'] ?? false), + 'effective_from' => (string)($fixed_version['effective_from'] ?? ''), + 'effective_to' => $fixed_version['effective_to'] ?? null, + 'original_price' => 0.0, + 'department_totals' => [], + 'order_ids' => [], + ]; + } + + $order_original_price = $this->calculateOrderOriginalPrice( + $order_items_by_order_id[$order_id] ?? [], + $customer_number, + $department_id, + $created_at + ); + $groups[$group_key]['original_price'] += $order_original_price; + if (!isset($groups[$group_key]['department_totals'][$department_id])) { + $groups[$group_key]['department_totals'][$department_id] = 0.0; + } + $groups[$group_key]['department_totals'][$department_id] += $order_original_price; + $groups[$group_key]['order_ids'][] = $order_id; + + if (!isset($customer_transactions[$customer_number][$order_id])) { + $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id); + } + } + + return [ + 'groups' => $groups, + 'customer_transactions' => $customer_transactions, + 'warnings' => $warnings, + ]; + } + + protected function collectWashSubscriptionData(array $orders, array $order_items_by_order_id, bool $system_order_fallback = false): array + { + $groups = []; + $customer_transactions = []; + $customer_department_month_map = []; + $warnings = []; + + foreach ($orders as $order) { + $order_id = (int)$order['id']; + $customer_number = (int)$order['customer_id']; + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + + if ($system_order_fallback) { + if (!$this->isSystemOrderCandidate($order, self::WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE)) { + continue; + } + } elseif (!$this->isDepartmentEligible($department_id)) { + continue; + } + + $month_key = substr($created_at, 0, 7); + if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) { + $customer_department_month_map[$customer_number][$month_key][$department_id] = 0; + } + $customer_department_month_map[$customer_number][$month_key][$department_id]++; + + $candidates = $this->buildWashSubscriptionCandidates( + $order, + $order_items_by_order_id[$order_id] ?? [], + $system_order_fallback + ); + if (empty($candidates)) { + continue; + } + + $active_subscriptions = $this->versioning->resolveVehicleSubscriptionVersionsAt($customer_number, $created_at); + $matched_order = false; + foreach ($candidates as $candidate) { + $matching_version = $this->findMatchingSubscriptionVersion( + $active_subscriptions, + (string)$candidate['reg'], + (int)$candidate['vehicle_type'] + ); + if ($matching_version === null) { + continue; + } + + $monthly_price = $this->getSubscriptionMonthlyPrice((int)$matching_version['vehicle_type']); + if ($monthly_price <= 0.0) { + $warnings[] = 'Subscription type ' . (int)$matching_version['vehicle_type'] . ' has no monthly price for customer ' . $customer_number; + continue; + } + + $group_key = $customer_number . '|' . (string)$matching_version['reg'] . '|' . (int)$matching_version['id'] . '|' . $month_key; + if (!isset($groups[$group_key])) { + $groups[$group_key] = [ + 'customer_number' => $customer_number, + 'reg' => (string)$matching_version['reg'], + 'vehicle_type' => (int)$matching_version['vehicle_type'], + 'version_id' => (int)$matching_version['id'], + 'month' => $month_key, + 'monthly_price' => $monthly_price, + 'source' => (string)($matching_version['source'] ?? 'unknown'), + 'confidence' => (float)($matching_version['confidence'] ?? 0), + 'inferred' => (bool)($matching_version['inferred'] ?? false), + 'distribution' => [], + 'order_ids' => [], + 'fallback' => false, + ]; + } + + if (!isset($groups[$group_key]['distribution'][$department_id])) { + $groups[$group_key]['distribution'][$department_id] = 0; + } + $groups[$group_key]['distribution'][$department_id]++; + $groups[$group_key]['order_ids'][] = $order_id; + $matched_order = true; + } + + if ($matched_order && !isset($customer_transactions[$customer_number][$order_id])) { + $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id); + } + } + + return [ + 'groups' => $groups, + 'customer_transactions' => $customer_transactions, + 'customer_department_month_map' => $customer_department_month_map, + 'warnings' => $warnings, + ]; + } + + protected function buildDateRange(string $date_from, string $date_to): array { $from = date('Y-m-d 00:00:00', strtotime($date_from)); $to = date('Y-m-d 23:59:59', strtotime($date_to)); return [$from, $to]; } - private function fetchOrdersInRange(string $from_ts, string $to_ts): array + protected function fetchOrdersInRange(string $from_ts, string $to_ts): array { $cache_key = $from_ts . '|' . $to_ts; if (isset($this->orders_in_range_cache[$cache_key])) { @@ -504,7 +634,7 @@ class economic_v2_distribution_service global $db; $from = $db->escape_string($from_ts); $to = $db->escape_string($to_ts); - $sql = "SELECT id, customer_id, department_id, created_at, reg_1 + $sql = "SELECT id, customer_id, department_id, created_at, reg_1, reference FROM orders WHERE deleted_at IS NULL AND created_at >= '$from' @@ -518,7 +648,7 @@ class economic_v2_distribution_service return $this->orders_in_range_cache[$cache_key]; } - private function fetchOrderItemsByOrderIds(array $order_ids): array + protected function fetchOrderItemsByOrderIds(array $order_ids): array { global $db; $order_ids = array_values(array_unique(array_filter(array_map('intval', $order_ids), static fn($id) => $id > 0))); @@ -532,7 +662,7 @@ class economic_v2_distribution_service return $this->order_items_by_order_ids_cache[$cache_key]; } - $sql = "SELECT order_id, product_id, price, quantity + $sql = "SELECT order_id, product_id, price, quantity, reference FROM order_items WHERE deleted_at IS NULL AND order_id IN (" . implode(',', $order_ids) . ")"; @@ -554,7 +684,7 @@ class economic_v2_distribution_service return $this->order_items_by_order_ids_cache[$cache_key]; } - private function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array + protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array { $cache_key = $from_ts . '|' . $to_ts; if (isset($this->vehicle_subscription_versions_in_range_cache[$cache_key])) { @@ -591,6 +721,68 @@ class economic_v2_distribution_service return true; } + protected function buildWashSubscriptionCandidates(array $order, array $order_items, bool $system_order_fallback = false): array + { + if (!$system_order_fallback) { + $reg = trim((string)($order['reg_1'] ?? '')); + if ($reg === '') { + return []; + } + + return [[ + 'reg' => $reg, + 'vehicle_type' => 0, + ]]; + } + + $candidates = []; + foreach ($order_items as $item) { + $reg = trim((string)($item['reference'] ?? '')); + if ($reg === '') { + continue; + } + + $vehicle_type = (int)($item['product_id'] ?? 0); + $candidate_key = strtoupper($reg) . '|' . $vehicle_type; + if (isset($candidates[$candidate_key])) { + continue; + } + + $candidates[$candidate_key] = [ + 'reg' => $reg, + 'vehicle_type' => $vehicle_type, + ]; + } + + return array_values($candidates); + } + + protected function findMatchingSubscriptionVersion(array $active_subscriptions, string $reg, int $vehicle_type = 0): ?array + { + $fallback_match = null; + foreach ($active_subscriptions as $candidate) { + if (strcasecmp((string)$candidate['reg'], $reg) !== 0) { + continue; + } + + if ($vehicle_type > 0 && (int)($candidate['vehicle_type'] ?? 0) === $vehicle_type) { + return $candidate; + } + + if ($fallback_match === null) { + $fallback_match = $candidate; + } + } + + return $fallback_match; + } + + protected function isSystemOrderCandidate(array $order, string $reference): bool + { + return (int)($order['department_id'] ?? 0) === self::SYSTEM_ORDER_DEPARTMENT_ID + && strcasecmp(trim((string)($order['reference'] ?? '')), $reference) === 0; + } + private function buildSubscriptionFallbackDistribution( int $customer_number, string $month_key, @@ -652,7 +844,7 @@ class economic_v2_distribution_service return array_map(static fn($amount) => (float)$amount, $distribution); } - private function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float + protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float { $total = 0.0; foreach ($order_items as $item) { @@ -679,7 +871,7 @@ class economic_v2_distribution_service return $total; } - private function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array + protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array { $cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19); if (array_key_exists($cache_key, $this->discount_resolution_cache)) { @@ -705,7 +897,7 @@ class economic_v2_distribution_service return $this->discount_resolution_cache[$cache_key] = null; } - private function getProductDepartmentPrice(int $product_id, int $department_id): float + protected function getProductDepartmentPrice(int $product_id, int $department_id): float { if (!isset($this->product_department_price_cache[$department_id][$product_id])) { $product = $this->getProduct($product_id); @@ -718,7 +910,7 @@ class economic_v2_distribution_service return (float)$this->product_department_price_cache[$department_id][$product_id]; } - private function getSubscriptionMonthlyPrice(int $vehicle_type): float + protected function getSubscriptionMonthlyPrice(int $vehicle_type): float { $product = $this->getProduct($vehicle_type); if ($product === null) { @@ -741,7 +933,7 @@ class economic_v2_distribution_service return $this->product_cache[$product_id]; } - private function buildCustomerEnvelope(int $customer_number, array $transaction_map): array + protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array { return [ 'id' => (new users_o())->getUserByCustomerNumber($customer_number)->id, @@ -753,7 +945,7 @@ class economic_v2_distribution_service ]; } - private function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): array + protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): array { $order = (new orders_o())->select($order_id); return [ @@ -774,9 +966,9 @@ class economic_v2_distribution_service return (string)$this->customer_name_cache[$customer_number]; } - private function isDepartmentEligible(int $department_id): bool + protected function isDepartmentEligible(int $department_id): bool { - if ($department_id === 10 || $department_id <= 0) { + if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { return false; } if (!array_key_exists($department_id, $this->department_excluded_cache)) { @@ -789,7 +981,7 @@ class economic_v2_distribution_service return !$this->department_excluded_cache[$department_id]; } - private function parseDepartmentMap(array $department_map): array + protected function parseDepartmentMap(array $department_map): array { $parsed = []; foreach ($department_map as $department_id => $amount) { diff --git a/services/nginx/app/classes/n8n.php b/services/nginx/app/classes/n8n.php new file mode 100644 index 00000000..ef60119c --- /dev/null +++ b/services/nginx/app/classes/n8n.php @@ -0,0 +1,587 @@ +config = new n8n_c(); + } + + /** + * @throws Exception + */ + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The n8n module is not enabled.'); + } + } + + /** + * @throws Exception + */ + public function listWorkflows(array $filters = []): object + { + return $this->sendApiRequest('GET', '/workflows', $this->filterAllowed($filters, [ + 'active', + 'tags', + 'name', + 'projectId', + 'excludePinnedData', + 'limit', + 'cursor', + ])); + } + + /** + * @throws Exception + */ + public function getWorkflow(string $id, bool $excludePinnedData = false): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + $query = []; + if ($excludePinnedData) { + $query['excludePinnedData'] = true; + } + + return $this->sendApiRequest('GET', '/workflows/' . rawurlencode($id), $query); + } + + /** + * @throws Exception + */ + public function createWorkflow(object $workflow): object + { + return $this->sendApiRequest('POST', '/workflows', [], $this->sanitizeWorkflowPayload($workflow)); + } + + /** + * @throws Exception + */ + public function updateWorkflow(string $id, object $changes): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + $existing = $this->getWorkflow($id); + $merged = $this->mergeWorkflowPayload($existing, $changes); + + return $this->sendApiRequest('PUT', '/workflows/' . rawurlencode($id), [], $merged); + } + + /** + * @throws Exception + */ + public function publishWorkflow(string $id, ?object $options = null): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + return $this->sendApiRequest( + 'POST', + '/workflows/' . rawurlencode($id) . '/activate', + [], + $options !== null ? $this->filterPublishOptions($options) : null + ); + } + + /** + * @throws Exception + */ + public function deactivateWorkflow(string $id): object + { + $this->requireValidIdentifier($id, 'workflow id'); + + return $this->sendApiRequest('POST', '/workflows/' . rawurlencode($id) . '/deactivate'); + } + + /** + * @throws Exception + */ + public function runWebhook(string $webhookTarget, mixed $payload = null, string $method = 'POST', array $query = []): object + { + $this->requireModuleEnabled(); + + $url = $this->resolveWebhookUrl($webhookTarget); + $normalizedMethod = $this->normalizeMethod($method); + + return $this->sendWebhookRequest($normalizedMethod, $url, $query, $payload); + } + + /** + * @throws Exception + */ + public function listExecutions(array $filters = []): object + { + return $this->sendApiRequest('GET', '/executions', $this->filterAllowed($filters, [ + 'includeData', + 'status', + 'workflowId', + 'projectId', + 'limit', + 'cursor', + ])); + } + + /** + * @throws Exception + */ + public function getExecution(int $id, bool $includeData = false): object + { + $this->requirePositiveInteger($id, 'execution id'); + + $query = []; + if ($includeData) { + $query['includeData'] = true; + } + + return $this->sendApiRequest('GET', '/executions/' . $id, $query); + } + + /** + * @throws Exception + */ + public function retryExecution(int $id, bool $loadWorkflow = false): object + { + $this->requirePositiveInteger($id, 'execution id'); + + $payload = null; + if ($loadWorkflow) { + $payload = (object)['loadWorkflow' => true]; + } + + return $this->sendApiRequest('POST', '/executions/' . $id . '/retry', [], $payload); + } + + /** + * @throws Exception + */ + public function stopExecution(int $id): object + { + $this->requirePositiveInteger($id, 'execution id'); + + return $this->sendApiRequest('POST', '/executions/' . $id . '/stop'); + } + + /** + * @throws Exception + */ + private function sendApiRequest(string $method, string $path, array $query = [], ?object $body = null): object + { + $this->requireModuleEnabled(); + $this->requireConfiguredApiUrl(); + $this->requireConfiguredApiKey(); + + $url = $this->buildUrl($this->config->api_url->getVariableValue(), $path, $query); + $headers = [ + 'Accept: application/json', + 'X-N8N-API-KEY: ' . trim((string)$this->config->api_key->getVariableValue()), + ]; + + return $this->executeJsonRequest($method, $url, $headers, $body); + } + + /** + * @throws Exception + */ + private function sendWebhookRequest(string $method, string $url, array $query = [], mixed $body = null): object + { + $headers = ['Accept: application/json']; + $payload = null; + + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + $payload = json_encode($body, JSON_UNESCAPED_UNICODE); + if ($payload === false) { + throw new Exception('Unable to encode n8n webhook payload as JSON.'); + } + } + + $response = $this->executeRequest($method, $this->buildUrl($url, '', $query), $headers, $payload); + if ($response['status'] >= 400) { + throw new Exception($this->extractErrorMessage($response['body'], $response['status'], 'Webhook request failed')); + } + + $decoded = json_decode($response['body']); + if (json_last_error() === JSON_ERROR_NONE) { + if (is_object($decoded)) { + $decoded->status_code = $response['status']; + return $decoded; + } + + return (object)[ + 'status_code' => $response['status'], + 'data' => $decoded, + ]; + } + + return (object)[ + 'status_code' => $response['status'], + 'body' => $response['body'], + ]; + } + + /** + * @throws Exception + */ + private function executeJsonRequest(string $method, string $url, array $headers, ?object $body = null): object + { + $payload = null; + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + $payload = json_encode($body, JSON_UNESCAPED_UNICODE); + if ($payload === false) { + throw new Exception('Unable to encode n8n request body as JSON.'); + } + } + + $response = $this->executeRequest($method, $url, $headers, $payload); + $decoded = json_decode($response['body']); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('Invalid JSON response from n8n (HTTP ' . $response['status'] . ').'); + } + + if ($response['status'] >= 400) { + throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'n8n API request failed')); + } + + if (is_object($decoded)) { + return $decoded; + } + + return (object)[ + 'data' => $decoded, + ]; + } + + /** + * @throws Exception + */ + private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array + { + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($curl); + $statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + + if ($error !== '') { + throw new Exception('cURL request to n8n failed: ' . $error); + } + + if ($responseBody === false) { + throw new Exception('n8n request returned an empty response.'); + } + + return [ + 'status' => $statusCode, + 'body' => (string)$responseBody, + ]; + } + + private function buildUrl(string $baseUrl, string $path = '', array $query = []): string + { + $url = rtrim(trim($baseUrl), '/'); + if ($path !== '') { + $url .= '/' . ltrim($path, '/'); + } + + $query = array_filter($query, static function (mixed $value): bool { + return $value !== null && $value !== ''; + }); + + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + return $url; + } + + /** + * @throws Exception + */ + private function resolveWebhookUrl(string $target): string + { + $target = trim($target); + if ($target === '') { + throw new Exception('Webhook target must not be empty.'); + } + + if (filter_var($target, FILTER_VALIDATE_URL) !== false) { + return $target; + } + + $baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue()); + if ($baseUrl === '') { + throw new Exception('n8n webhook base URL is not configured.'); + } + + return rtrim($baseUrl, '/') . '/' . ltrim($target, '/'); + } + + /** + * @throws Exception + */ + private function sanitizeWorkflowPayload(object $workflow): object + { + $payload = $this->cloneObject($workflow); + + foreach (self::WORKFLOW_READ_ONLY_FIELDS as $field) { + if (property_exists($payload, $field)) { + unset($payload->{$field}); + } + } + + if (!property_exists($payload, 'name') || !is_string($payload->name) || trim($payload->name) === '') { + throw new Exception('Workflow name is required.'); + } + + if (!property_exists($payload, 'nodes') || !is_array($payload->nodes)) { + throw new Exception('Workflow nodes are required and must be an array.'); + } + + if (!property_exists($payload, 'connections')) { + throw new Exception('Workflow connections are required.'); + } + + if (!property_exists($payload, 'settings') || $payload->settings === null) { + $payload->settings = new stdClass(); + } + + $payload->connections = $this->normalizeObjectValue($payload->connections, 'connections'); + $payload->settings = $this->normalizeObjectValue($payload->settings, 'settings'); + + if (property_exists($payload, 'staticData') && is_array($payload->staticData) && !array_is_list($payload->staticData)) { + $payload->staticData = $this->arrayToObject($payload->staticData); + } + + return $payload; + } + + private function mergeWorkflowPayload(object $existing, object $changes): object + { + $merged = $this->cloneObject($existing); + + foreach (get_object_vars($changes) as $key => $value) { + if (in_array($key, self::WORKFLOW_READ_ONLY_FIELDS, true)) { + continue; + } + + if (property_exists($merged, $key) && is_object($merged->{$key}) && is_object($value)) { + $merged->{$key} = $this->mergeObjects($merged->{$key}, $value); + continue; + } + + $merged->{$key} = $value; + } + + return $this->sanitizeWorkflowPayload($merged); + } + + private function mergeObjects(object $base, object $changes): object + { + foreach (get_object_vars($changes) as $key => $value) { + if (property_exists($base, $key) && is_object($base->{$key}) && is_object($value)) { + $base->{$key} = $this->mergeObjects($base->{$key}, $value); + continue; + } + + $base->{$key} = $value; + } + + return $base; + } + + /** + * @throws Exception + */ + private function normalizeObjectValue(mixed $value, string $field): object + { + if ($value instanceof stdClass || is_object($value)) { + return $value; + } + + if (is_array($value) && !array_is_list($value)) { + return $this->arrayToObject($value); + } + + if ($value === [] && $field === 'settings') { + return new stdClass(); + } + + throw new Exception('Workflow ' . $field . ' must be an object.'); + } + + private function arrayToObject(array $value): object + { + $object = new stdClass(); + + foreach ($value as $key => $item) { + $object->{$key} = $this->normalizeMixedValue($item); + } + + return $object; + } + + private function normalizeMixedValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + if (array_is_list($value)) { + return array_map(fn (mixed $item): mixed => $this->normalizeMixedValue($item), $value); + } + + return $this->arrayToObject($value); + } + + private function cloneObject(object $value): object + { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + return clone $value; + } + + $decoded = json_decode($encoded); + return is_object($decoded) ? $decoded : clone $value; + } + + private function filterPublishOptions(object $options): object + { + $filtered = new stdClass(); + + foreach (['versionId', 'name', 'description'] as $field) { + if (property_exists($options, $field) && $options->{$field} !== null && $options->{$field} !== '') { + $filtered->{$field} = $options->{$field}; + } + } + + return $filtered; + } + + private function filterAllowed(array $filters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($filters as $key => $value) { + if (isset($allowed[$key])) { + $filtered[$key] = $value; + } + } + + return $filtered; + } + + /** + * @throws Exception + */ + private function requireConfiguredApiUrl(): void + { + $url = trim((string)$this->config->api_url->getVariableValue()); + if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) { + throw new Exception('Invalid n8n API URL configured.'); + } + } + + /** + * @throws Exception + */ + private function requireConfiguredApiKey(): void + { + if (trim((string)$this->config->api_key->getVariableValue()) === '') { + throw new Exception('Invalid n8n API key configured.'); + } + } + + private function normalizeUrlForValidation(string $url): string + { + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + return 'http://' . ltrim($url, '/'); + } + + /** + * @throws Exception + */ + private function requireValidIdentifier(string $value, string $label): void + { + if (trim($value) === '') { + throw new Exception('Invalid ' . $label . '.'); + } + } + + /** + * @throws Exception + */ + private function requirePositiveInteger(int $value, string $label): void + { + if ($value <= 0) { + throw new Exception('Invalid ' . $label . '.'); + } + } + + private function normalizeMethod(string $method): string + { + $normalized = strtoupper(trim($method)); + if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return 'POST'; + } + + return $normalized; + } + + private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string + { + if (is_object($decoded)) { + if (isset($decoded->message) && is_string($decoded->message)) { + return $decoded->message; + } + if (isset($decoded->error) && is_string($decoded->error)) { + return $decoded->error; + } + } + + if (is_string($decoded) && trim($decoded) !== '') { + return $decoded; + } + + return $fallback . ' (HTTP ' . $statusCode . ').'; + } +} diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php new file mode 100644 index 00000000..e07c3d15 --- /dev/null +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -0,0 +1,151 @@ +query($sql); + } + + self::ensureColumn( + 'department_lanes', + 'machine_type_id', + 'ALTER TABLE department_lanes ADD COLUMN machine_type_id INT NULL AFTER dynamic_image_id' + ); + self::ensureColumn( + 'department_selfserve_conditions', + 'machine_type_id', + 'ALTER TABLE department_selfserve_conditions ADD COLUMN machine_type_id INT NULL AFTER product' + ); + self::ensureColumn( + 'department_selfserve_tasks', + 'machine_type_id', + 'ALTER TABLE department_selfserve_tasks ADD COLUMN machine_type_id INT NULL AFTER product' + ); + + self::$initialized = true; + } + + public static function tableHasColumn(string $table, string $column): bool + { + global $db; + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $sql = "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'"; + $result = $db->query($sql); + if (!$result) { + return false; + } + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } + + public static function ensureColumn(string $table, string $column, string $alterSql): void + { + global $db; + if (self::tableHasColumn($table, $column)) { + return; + } + $db->query($alterSql); + } +} diff --git a/services/nginx/app/interfaces/n8n_i.php b/services/nginx/app/interfaces/n8n_i.php new file mode 100644 index 00000000..33a761b5 --- /dev/null +++ b/services/nginx/app/interfaces/n8n_i.php @@ -0,0 +1,28 @@ +setupConfig('n8n'); + $this->allowUpdate([ + n8n_enabled_c::class, + n8n_api_url_c::class, + n8n_api_key_c::class, + n8n_webhook_base_url_c::class, + ]); + + $this->enabled = new n8n_enabled_c(); + $this->api_url = new n8n_api_url_c(); + $this->api_key = new n8n_api_key_c(); + $this->webhook_base_url = new n8n_webhook_base_url_c(); + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php new file mode 100644 index 00000000..35e043ed --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php @@ -0,0 +1,134 @@ +isRuleSatisfied($rule, $answers, $resolver)) { + $andSatisfied = false; + break; + } + } + + $orSatisfied = true; + if ($orRules !== []) { + $orSatisfied = false; + foreach ($orRules as $rule) { + if ($this->isRuleSatisfied($rule, $answers, $resolver)) { + $orSatisfied = true; + break; + } + } + } + + unset($resolving[$conditionId]); + $results[$conditionId] = $andSatisfied && $orSatisfied; + return $results[$conditionId]; + }; + + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId <= 0) { + continue; + } + $resolver($conditionId); + } + + return $results; + } + + public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool + { + if ($gateId === null || $gateId <= 0) { + return true; + } + if (array_key_exists($gateId, $conditionResults)) { + return $conditionResults[$gateId] === true; + } + return ($answers[$gateId] ?? null) === true; + } + + /** + * @param array $rule + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return bool + */ + private function isRuleSatisfied(array $rule, array $answers, callable $conditionResolver): bool + { + $ruleType = selfserve_condition_rule_type::tryFrom((string)($rule['type'] ?? '')); + if ($ruleType === null) { + return false; + } + + $objectType = selfserve_condition_rule_object_type::tryFrom((string)($rule['object_type'] ?? '')); + if ($objectType === null) { + return false; + } + + $objectId = (int)($rule['object_id'] ?? 0); + $value = match ($objectType) { + selfserve_condition_rule_object_type::QUESTION => ($answers[$objectId] ?? null), + selfserve_condition_rule_object_type::CONDITION => $conditionResolver($objectId), + }; + + return match ($ruleType) { + selfserve_condition_rule_type::IS_TRUE, + selfserve_condition_rule_type::IS_TRUE_OR_ANY_TRUE => $value === true, + selfserve_condition_rule_type::IS_FALSE => $value === false, + selfserve_condition_rule_type::IS_SET => $value !== null, + selfserve_condition_rule_type::IS_TRUE_OR_NOT_SET => $value === true || $value === null, + selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET => $value === false || $value === null, + }; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php new file mode 100644 index 00000000..f05d6054 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -0,0 +1,612 @@ +conditionEvaluator ??= new selfserve_condition_evaluator(); + } + + public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null): array + { + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber); + $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); + + return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); + } + + public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true): array + { + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber); + $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); + + if (!$session->exists()) { + $session = (new selfserve_wash_sessions_o())->add( + $laneId, + (int)$snapshot['lane']['department'], + $snapshot['machine_type']['id'] ?? null, + $snapshot['customer_number'], + $snapshot['reg'], + $snapshot['vehicle']['id'] ?? null, + $snapshot['vehicle']['type'] ?? null, + $this->deriveBaseStatus($snapshot), + (bool)$snapshot['allowed'], + $this->buildSessionMetadata($snapshot), + ); + } else { + $session->allowed->set((bool)$snapshot['allowed']); + $session->metadata_json->set($this->buildSessionMetadata($snapshot)); + $session->updateStatus($this->deriveCurrentStatus($snapshot, $session)); + } + + $this->syncSessionAnswers((int)$session->id, $snapshot['questions']); + $this->syncSessionTasks((int)$session->id, $snapshot['tasks']); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [ + 'allowed' => (bool)$snapshot['allowed'], + 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], + 'allowed_services' => $snapshot['allowed_services'], + 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), + ]); + + if ($activateMachine && (bool)$snapshot['allowed']) { + $this->enableMachineRelayIfAllowed($snapshot, $session); + } + + return $this->getSessionSummary((int)$session->id); + } + + public function recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = []): array + { + $normalizedReg = $reg === null ? null : selfserve::standardize_registration($reg); + $session = $normalizedReg !== null + ? $this->findLatestOpenSession($laneId, $normalizedReg) + : $this->findLatestOpenSessionByLane($laneId); + + if (!$session->exists()) { + if ($normalizedReg === null) { + throw new \RuntimeException('No active self-serve wash session found for the lane.'); + } + $summary = $this->synchronizeSession($laneId, $normalizedReg, null, false); + $session = (new selfserve_wash_sessions_o())->select((int)$summary['session']['id']); + } + + $lane = (new selfserve())->lane($laneId); + $effectiveReg = $normalizedReg ?? (string)$session->reg->value(); + $customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); + + if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) { + $lane->setLaneStatus(selfserve_lane_status::OCCUPIED); + } + if (!$lane->getLaneState()->equals(selfserve_lane_state::IN_WASH)) { + $lane->setLaneState(selfserve_lane_state::IN_WASH); + } + if ($effectiveReg !== '') { + $lane->setLicensePlate($effectiveReg); + } + if ($customerNumber !== null && $customerNumber > 0) { + $lane->setCustomerNumber($customerNumber); + } + if ((int)$lane->getWashStartTime() <= 0) { + $lane->setWashStartTime(time()); + } + + $session->markMachineStartTriggered(); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_START_TRIGGERED, $payload + [ + 'lane_id' => $laneId, + 'reg' => $effectiveReg, + 'customer_number' => $customerNumber, + ]); + + return $this->getSessionSummary((int)$session->id); + } + + public function getSessionSummary(int $sessionId): array + { + $session = (new selfserve_wash_sessions_o())->select($sessionId); + if (!$session->exists()) { + throw new \RuntimeException('Self-serve wash session not found.'); + } + + $lane = (new department_lanes_o())->select((int)$session->lane_id->value()); + $machineType = null; + if ($session->machine_type_id->value() !== null) { + $machineTypeObject = (new selfserve_machine_types_o())->select((int)$session->machine_type_id->value()); + if ($machineTypeObject->exists()) { + $machineType = $machineTypeObject->asArray(); + } + } + + $answers = array_map(function (array $row): array { + return [ + 'question_id' => (int)$row['question_id'], + 'question' => (string)$row['question_text'], + 'answer' => (bool)$row['answer_value'], + 'answered_at' => (string)$row['answered_at'], + ]; + }, (new selfserve_wash_session_answers_o())->listBySession($sessionId)); + + $tasks = array_map(function (array $row): array { + return [ + 'task_id' => $row['task_id'] === null ? null : (int)$row['task_id'], + 'task' => (string)$row['task_text'], + 'description' => $row['description'] === null ? null : (string)$row['description'], + 'services' => $this->normalizeJsonArray($row['services'] ?? null), + 'buttons' => $this->normalizeJsonArray($row['buttons'] ?? null), + ]; + }, (new selfserve_wash_session_tasks_o())->listBySession($sessionId)); + + $events = array_map(function (array $row): array { + return [ + 'id' => (int)$row['id'], + 'type' => (string)$row['event_type'], + 'payload' => $this->normalizeJsonValue($row['payload_json'] ?? null), + 'created_at' => (string)$row['created_at'], + ]; + }, (new selfserve_wash_session_events_o())->listBySession($sessionId)); + + return [ + 'session' => $session->asArray(), + 'lane' => $lane->exists() ? $lane->asArray() : null, + 'machine_type' => $machineType, + 'questions' => $answers, + 'tasks' => $tasks, + 'events' => $events, + ]; + } + + public function getLatestSessionSummary(int $laneId, string $reg): array + { + $session = (new selfserve_wash_sessions_o())->selectLatestByLaneAndReg($laneId, selfserve::standardize_registration($reg)); + if (!$session->exists()) { + throw new \RuntimeException('No self-serve wash session found for the lane and vehicle.'); + } + + return $this->getSessionSummary((int)$session->id); + } + + public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array + { + $session = $reg !== null + ? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber) + : $this->findLatestOpenSessionByLane($laneId, $customerNumber); + + if (!$session->exists()) { + return null; + } + + $session->markCompleted($orderId); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [ + 'lane_id' => $laneId, + 'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg), + 'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()), + 'order_id' => $orderId, + ]); + + return $this->getSessionSummary((int)$session->id); + } + + protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null): array + { + $normalizedReg = selfserve::standardize_registration($reg); + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + throw new \RuntimeException('Department lane not found.'); + } + + $departmentId = (int)$lane->department->value(); + $machineTypeId = $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(); + $vehicle = $this->findVehicleByRegistration($normalizedReg); + $vehicleData = $vehicle?->asArray(); + $vehicleTypeId = $vehicle !== null ? (int)$vehicle->type->value() : null; + $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); + + $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId); + $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId); + $rules = $this->loadConditionRules($conditions); + $answers = (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle($departmentId, $laneId, $normalizedReg); + $conditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); + + $visibleQuestions = []; + foreach ($questions as $question) { + $gateId = $this->nullableInt($question['condition_id'] ?? null); + if ($gateId !== null && (($conditionResults[$gateId] ?? false) !== true)) { + continue; + } + $questionId = (int)$question['id']; + $visibleQuestions[] = [ + 'id' => $questionId, + 'question' => (string)$question['question'], + 'description' => (string)($question['description'] ?? ''), + 'condition_id' => $gateId, + 'order_priority' => (int)($question['order_priority'] ?? 0), + 'answer' => array_key_exists($questionId, $answers) ? (bool)$answers[$questionId] : null, + ]; + } + + usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); + + $tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId); + $activeTasks = []; + foreach ($tasks as $task) { + $gateId = $this->nullableInt($task['condition_id'] ?? null); + if (!$this->conditionEvaluator->taskGateSatisfied($gateId, $conditionResults, $answers)) { + continue; + } + + $activeTasks[] = [ + 'id' => (int)$task['id'], + 'task' => (string)$task['task'], + 'description' => (string)($task['description'] ?? ''), + 'condition_id' => $gateId, + 'order_priority' => (int)($task['order_priority'] ?? 0), + 'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), + 'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)), + ]; + } + usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); + + $allowedServices = []; + foreach ($activeTasks as $task) { + foreach ($task['services'] as $service) { + if (!in_array($service, $allowedServices, true)) { + $allowedServices[] = $service; + } + } + } + + $machineAvailable = !empty($lane->relay_machine_id->value()); + $allVisibleQuestionsAnswered = true; + foreach ($visibleQuestions as $question) { + if ($question['answer'] === null) { + $allVisibleQuestionsAnswered = false; + break; + } + } + + $machineAllowed = $allVisibleQuestionsAnswered + && $machineAvailable + && in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true); + + $machineType = null; + if ($machineTypeId !== null) { + $machineTypeObject = (new selfserve_machine_types_o())->select($machineTypeId); + if ($machineTypeObject->exists()) { + $machineType = $machineTypeObject->asArray(); + } + } + + return [ + 'lane' => $lane->asArray(), + 'machine_type' => $machineType, + 'vehicle' => $vehicleData, + 'reg' => $normalizedReg, + 'customer_number' => $resolvedCustomerNumber, + 'vehicle_type_id' => $vehicleTypeId, + 'answers' => $answers, + 'questions' => $visibleQuestions, + 'conditions' => $conditionResults, + 'tasks' => $activeTasks, + 'allowed_services' => $allowedServices, + 'machine_available' => $machineAvailable, + 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, + 'allowed' => $machineAllowed, + ]; + } + + protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void + { + if ((bool)$session->machine_relay_enabled->value() === true) { + return; + } + + $laneId = (int)$snapshot['lane']['id']; + $lane = (new selfserve())->lane($laneId); + $lane->setLaneCache($laneId, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $snapshot['allowed_services']); + $lane->turnOnRelay(selfserve_lane_relay::MACHINE); + + $session->markRelayEnabled(); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [ + 'lane_id' => $laneId, + 'reg' => $snapshot['reg'], + 'allowed_services' => $snapshot['allowed_services'], + 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), + ]); + } + + protected function deriveBaseStatus(array $snapshot): selfserve_wash_session_status + { + if (!$snapshot['all_visible_questions_answered']) { + return selfserve_wash_session_status::PENDING_QUESTIONS; + } + if ($snapshot['allowed']) { + return selfserve_wash_session_status::READY_FOR_MACHINE_START; + } + return selfserve_wash_session_status::MACHINE_NOT_ALLOWED; + } + + protected function deriveCurrentStatus(array $snapshot, selfserve_wash_sessions_o $session): selfserve_wash_session_status + { + if ($session->completed_at->value() !== null) { + return selfserve_wash_session_status::COMPLETED; + } + if ((bool)$session->machine_start_triggered->value() === true) { + return selfserve_wash_session_status::MACHINE_STARTED; + } + if ((bool)$session->machine_relay_enabled->value() === true) { + return selfserve_wash_session_status::MACHINE_RELAY_ENABLED; + } + return $this->deriveBaseStatus($snapshot); + } + + protected function formatSnapshotResponse(array $snapshot, ?array $session = null): array + { + return [ + 'lane' => $snapshot['lane'], + 'machine_type' => $snapshot['machine_type'], + 'vehicle' => $snapshot['vehicle'], + 'reg' => $snapshot['reg'], + 'customer_number' => $snapshot['customer_number'], + 'questions' => $snapshot['questions'], + 'tasks' => $snapshot['tasks'], + 'allowed_services' => $snapshot['allowed_services'], + 'machine_available' => $snapshot['machine_available'], + 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], + 'allowed' => $snapshot['allowed'], + 'session' => $session, + ]; + } + + protected function loadQuestions(int $departmentId, int $laneId, ?int $vehicleTypeId): array + { + $questionsObject = new department_selfserve_questions_o(); + $sharedQuestions = $questionsObject->getSharedQuestions(); + if ($sharedQuestions !== []) { + return $sharedQuestions; + } + if ($vehicleTypeId === null) { + return []; + } + + return $questionsObject->getLegacyQuestionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); + } + + protected function loadConditions(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId): array + { + $conditionsObject = new department_selfserve_conditions_o(); + if ($machineTypeId !== null) { + $machineTypeConditions = $conditionsObject->getConditionsForMachineType($machineTypeId); + if ($machineTypeConditions !== []) { + return $machineTypeConditions; + } + } + if ($vehicleTypeId === null) { + return []; + } + + return $conditionsObject->getLegacyConditionsForLaneProduct($departmentId, $laneId, $vehicleTypeId); + } + + protected function loadTasks(int $departmentId, int $laneId, ?int $vehicleTypeId, ?int $machineTypeId): array + { + $tasksObject = new department_selfserve_tasks_o(); + if ($machineTypeId !== null) { + $machineTypeTasks = $tasksObject->getTasksForMachineType($machineTypeId); + if ($machineTypeTasks !== []) { + return $machineTypeTasks; + } + } + if ($vehicleTypeId === null) { + return []; + } + + return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId); + } + + protected function loadConditionRules(array $conditions): array + { + if ($conditions === []) { + return []; + } + + $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions); + + return (new department_selfserve_condition_rules_o())->getFieldsWhereIn([ + 'condition_id' => $conditionIds, + 'deleted_at' => null, + ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']); + } + + protected function syncSessionAnswers(int $sessionId, array $questions): void + { + $answersObject = new selfserve_wash_session_answers_o(); + $answeredQuestionIds = []; + foreach ($questions as $question) { + if ($question['answer'] === null) { + continue; + } + $answeredQuestionIds[] = (int)$question['id']; + $answersObject->upsert( + $sessionId, + (int)$question['id'], + (string)$question['question'], + (bool)$question['answer'], + ); + } + $answersObject->deleteMissingForSession($sessionId, $answeredQuestionIds); + } + + protected function syncSessionTasks(int $sessionId, array $tasks): void + { + $tasksObject = new selfserve_wash_session_tasks_o(); + $tasksObject->deleteBySession($sessionId); + + foreach ($tasks as $task) { + $tasksObject->addSnapshot( + $sessionId, + (int)$task['id'], + (string)$task['task'], + (string)$task['description'], + $task['services'], + $task['buttons'], + ); + } + } + + protected function logSessionEvent(int $sessionId, selfserve_wash_event_type $eventType, ?array $payload = null): void + { + (new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload); + } + + protected function buildSessionMetadata(array $snapshot): array + { + return [ + 'allowed_services' => $snapshot['allowed_services'], + 'machine_available' => (bool)$snapshot['machine_available'], + 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], + 'visible_question_ids' => array_map(static fn(array $question): int => (int)$question['id'], $snapshot['questions']), + 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), + ]; + } + + protected function findVehicleByRegistration(string $reg): ?customer_vehicles_o + { + $vehicle = (new customer_vehicles_o())->selectByPlate($reg); + return $vehicle->exists() ? $vehicle : null; + } + + protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o + { + $session = new selfserve_wash_sessions_o(); + $session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber); + return $session; + } + + protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o + { + $rows = (new selfserve_wash_sessions_o())->getFieldsWhere( + [ + 'lane_id' => $laneId, + 'completed_at' => null, + 'deleted_at' => null, + ...($customerNumber !== null ? ['customer_number' => $customerNumber] : []), + ], + ['id'] + ); + if ($rows === []) { + return new selfserve_wash_sessions_o(); + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + return (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); + } + + protected function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 0 || $value === '0') { + return null; + } + + return (int)$value; + } + + protected function normalizeJsonValue(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + if (is_array($value)) { + return $value; + } + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + return $value; + } + + protected function normalizeJsonArray(mixed $value): array + { + $decoded = $this->normalizeJsonValue($value); + return is_array($decoded) ? $decoded : []; + } + + protected function normalizeServiceNames(array $services): array + { + $normalized = []; + foreach ($services as $service) { + $name = strtoupper(trim((string)$service)); + if ($name === '') { + continue; + } + if (!in_array($name, $normalized, true)) { + $normalized[] = $name; + } + } + return $normalized; + } + + protected function normalizeIntArray(array $values): array + { + $normalized = []; + foreach ($values as $value) { + $intValue = (int)$value; + if (!in_array($intValue, $normalized, true)) { + $normalized[] = $intValue; + } + } + return $normalized; + } +} diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_condition_rule_object_type.php b/services/nginx/app/modules/selfserve/helpers/selfserve_condition_rule_object_type.php new file mode 100644 index 00000000..fc2519f5 --- /dev/null +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_condition_rule_object_type.php @@ -0,0 +1,9 @@ +> $conditions + * @param array> $rules + * @param array $answers + * @return array + */ + public function evaluate(array $conditions, array $rules, array $answers): array; + + /** + * @param int|null $gateId + * @param array $conditionResults + * @param array $answers + * @return bool + */ + public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool; +} diff --git a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php new file mode 100644 index 00000000..66026620 --- /dev/null +++ b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php @@ -0,0 +1,18 @@ +logLaneAction(selfserve_lane_log_action::STOP_WASH); + // Finalize any active self-serve wash session before resetting lane state. + try { + (new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane( + $this->id, + $this->getLicensePlate() ?: null, + $this->getCustomerNumber() ?: null, + method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null + ); + } catch (\Throwable $e) { + // Session completion must not block STOP flow. + } // Reset the lane self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); break; @@ -151,4 +163,4 @@ trait selfserve_lane_command_t } return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index ebe03c95..d54ee565 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -13,6 +13,8 @@ use objects\orders_o; trait selfserve_lane_invoice_t { + public ?int $last_invoice_order_id = null; + /** * The product ID for minute-based billing * @var int|null $minute_billing_product_id @@ -34,6 +36,11 @@ trait selfserve_lane_invoice_t return $this->minute_billing_product_id; } + public function getLastInvoiceOrderId(): ?int + { + return $this->last_invoice_order_id; + } + /** * Invoice for minute-based billing * @return bool True on success, false on failure @@ -61,6 +68,7 @@ trait selfserve_lane_invoice_t (string)$this->getLicensePlate() ); $order->lane->set($this->id); + $this->last_invoice_order_id = (int)$order->id; // Add product to order $order_items = new order_items_o(); $order_items->addItemToOrder( @@ -71,4 +79,4 @@ trait selfserve_lane_invoice_t ); return true; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/department_lanes_o.php b/services/nginx/app/objects/department_lanes_o.php index 730a554d..5fe67805 100644 --- a/services/nginx/app/objects/department_lanes_o.php +++ b/services/nginx/app/objects/department_lanes_o.php @@ -5,6 +5,7 @@ namespace objects; use classes\db; use classes\object_property; use classes\selfserve; +use classes\selfserve_schema_bootstrap; use Exception; use traits\db_object_t; @@ -18,6 +19,7 @@ class department_lanes_o extends db public object_property $relay_out_id; // The Shelly relay for the exit port (if applicable) public object_property $relay_machine_id; // The Shelly relay for the machine (if applicable) public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable) + public object_property $machine_type_id; // The reusable self-serve machine type for the lane (if applicable) public object_property $created_at; public object_property $updated_at; public object_property $deleted_at; @@ -25,6 +27,7 @@ class department_lanes_o extends db public function structure(): void { + selfserve_schema_bootstrap::ensureTables(); $this->setTable('department_lanes'); } @@ -47,7 +50,7 @@ class department_lanes_o extends db * @return department_lanes_o * @throws Exception If the object was not created successfully */ - public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, int $dynamic_image_id = null): department_lanes_o + public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null): department_lanes_o { global /** @var db $db */ $db; @@ -69,6 +72,12 @@ class department_lanes_o extends db throw new Exception('dynamic_image_id must be a positive integer'); } } + if (!is_null($machine_type_id)) { + $machine_type_id = (int)$machine_type_id; + if ($machine_type_id <= 0) { + throw new Exception('machine_type_id must be a positive integer'); + } + } // Add the object $tmp_id = self::add_object([ 'department' => $department, @@ -77,6 +86,7 @@ class department_lanes_o extends db ...(!is_null($relay_out_id) ? ['relay_out_id' => $relay_out_id] : []), // If the relay_out_id is null, it will be set to null in the database ...(!is_null($relay_machine_id) ? ['relay_machine_id' => $relay_machine_id] : []), // If the relay_machine_id is null, it will be set to null in the database ...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database + ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), ]); $this->id = $tmp_id; self::getObjectProperties(); @@ -92,6 +102,7 @@ class department_lanes_o extends db $this->relay_out_id = new object_property($this->table, $this->id, 'relay_out_id', 'string', false); $this->relay_machine_id = new object_property($this->table, $this->id, 'relay_machine_id', 'string', false); $this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', 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); @@ -112,6 +123,7 @@ class department_lanes_o extends db 'relay_out_id' => (string)$this->relay_out_id->value(), 'relay_machine_id' => (string)$this->relay_machine_id->value(), 'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()), + 'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()), // Status of the lane 'status' => (string)$this->getLaneStatus()->name, // Timestamps @@ -152,4 +164,4 @@ class department_lanes_o extends db } return $lanes; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/department_selfserve_conditions_o.php b/services/nginx/app/objects/department_selfserve_conditions_o.php index 7640c4dd..dc560126 100644 --- a/services/nginx/app/objects/department_selfserve_conditions_o.php +++ b/services/nginx/app/objects/department_selfserve_conditions_o.php @@ -4,6 +4,7 @@ namespace objects; use classes\db; use classes\object_property; +use classes\selfserve_schema_bootstrap; use Exception; use traits\db_object_t; @@ -22,6 +23,7 @@ class department_selfserve_conditions_o extends db public object_property $department; // The department id public object_property $lane; // The lane id public object_property $product; // The product id + public object_property $machine_type_id; // The reusable machine type id (nullable, preferred over department/lane/product) public object_property $condition_id; // Parent condition id for nesting/grouping (nullable) public object_property $name; // The condition name public object_property $description; // The task description @@ -32,6 +34,7 @@ class department_selfserve_conditions_o extends db public function structure(): void { + selfserve_schema_bootstrap::ensureTables(); $this->setTable('department_selfserve_conditions'); } @@ -46,7 +49,7 @@ class department_selfserve_conditions_o extends db * @return department_selfserve_conditions_o * @throws Exception If the object was not created successfully */ - public function add(int $department, int $lane, int $product, string $name, string $description, int $condition_id = null): department_selfserve_conditions_o + public function add(int $department, int $lane, int $product, string $name, string $description, int $condition_id = null, ?int $machine_type_id = null): department_selfserve_conditions_o { global /** @var db $db */ $db; @@ -59,12 +62,16 @@ class department_selfserve_conditions_o extends db if (!is_null($condition_id)) { $condition_id = (int)$condition_id; } + if (!is_null($machine_type_id)) { + $machine_type_id = (int)$machine_type_id; + } // Add the object $tmp_id = self::add_object([ 'department' => $department, 'lane' => $lane, 'product' => $product, + ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), 'name' => $name, 'description' => $description, ...(!is_null($condition_id) ? ['condition_id' => $condition_id] : []), @@ -82,6 +89,7 @@ class department_selfserve_conditions_o extends db $this->department = new object_property($this->table, $this->id, 'department', 'int', false); $this->lane = new object_property($this->table, $this->id, 'lane', 'int', false); $this->product = new object_property($this->table, $this->id, 'product', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); $this->condition_id = new object_property($this->table, $this->id, 'condition_id', 'int', false); $this->name = new object_property($this->table, $this->id, 'name', 'string', false); $this->description = new object_property($this->table, $this->id, 'description', 'string', false); @@ -102,6 +110,7 @@ class department_selfserve_conditions_o extends db 'department' => (int)$this->department->value(), 'lane' => (int)$this->lane->value(), 'product' => (int)$this->product->value(), + 'machine_type_id' => is_null($this->machine_type_id->value()) ? null : (int)$this->machine_type_id->value(), 'condition_id' => is_null($this->condition_id->value()) ? null : (int)$this->condition_id->value(), 'name' => (string)$this->name->value(), 'description' => (string)$this->description->value(), @@ -110,4 +119,22 @@ class department_selfserve_conditions_o extends db 'updated_at' => (string)$this->updated_at->value(), ]; } + + public function getConditionsForMachineType(int $machineTypeId): array + { + return self::getFieldsWhere([ + 'machine_type_id' => $machineTypeId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']); + } + + public function getLegacyConditionsForLaneProduct(int $departmentId, int $laneId, int $productId): array + { + return self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'product' => $productId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']); + } } diff --git a/services/nginx/app/objects/department_selfserve_questions_o.php b/services/nginx/app/objects/department_selfserve_questions_o.php index 7ea2214a..427b7080 100644 --- a/services/nginx/app/objects/department_selfserve_questions_o.php +++ b/services/nginx/app/objects/department_selfserve_questions_o.php @@ -126,4 +126,24 @@ class department_selfserve_questions_o extends db 'updated_at' => (string)$this->updated_at->value(), ]; } + + public function getSharedQuestions(): array + { + return self::getFieldsWhere([ + 'department' => 0, + 'lane' => 0, + 'product' => 0, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']); + } + + public function getLegacyQuestionsForLaneProduct(int $departmentId, int $laneId, int $productId): array + { + return self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'product' => $productId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']); + } } diff --git a/services/nginx/app/objects/department_selfserve_tasks_o.php b/services/nginx/app/objects/department_selfserve_tasks_o.php index 0a14391b..13e142c2 100644 --- a/services/nginx/app/objects/department_selfserve_tasks_o.php +++ b/services/nginx/app/objects/department_selfserve_tasks_o.php @@ -4,6 +4,7 @@ namespace objects; use classes\db; use classes\object_property; +use classes\selfserve_schema_bootstrap; use Exception; use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_services; @@ -24,6 +25,7 @@ class department_selfserve_tasks_o extends db public object_property $department; // The department id public object_property $lane; // The lane id public object_property $product; // The product id + public object_property $machine_type_id; // The reusable machine type id (nullable, preferred over department/lane/product) public object_property $condition_id; // Legacy column name: stores question id that gates this task (nullable) public object_property $task; // The task public object_property $description; // The task description @@ -54,6 +56,7 @@ class department_selfserve_tasks_o extends db public function structure(): void { + selfserve_schema_bootstrap::ensureTables(); $this->setTable('department_selfserve_tasks'); } @@ -72,7 +75,7 @@ class department_selfserve_tasks_o extends db * @return department_selfserve_tasks_o * @throws Exception If the object was not created successfully */ - public function add(int $department, int $lane, int $product, int|null $question_id, string $task, string $description, int $order_priority = 0, ?array $services = null, array|string|null $buttons = null, int|null $dynamic_images_vehicle_type = null): self + public function add(int $department, int $lane, int $product, int|null $question_id, string $task, string $description, int $order_priority = 0, ?array $services = null, array|string|null $buttons = null, int|null $dynamic_images_vehicle_type = null, ?int $machine_type_id = null): self { global /** @var db $db */ $db; @@ -83,6 +86,9 @@ class department_selfserve_tasks_o extends db if (!is_null($question_id)) { $question_id = (int)$question_id; } + if (!is_null($machine_type_id)) { + $machine_type_id = (int)$machine_type_id; + } $task = $db->escape_string($task); $description = $db->escape_string($description); $order_priority = (int)$order_priority; @@ -132,6 +138,7 @@ class department_selfserve_tasks_o extends db 'department' => $department, 'lane' => $lane, 'product' => $product, + ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), ...(!is_null($question_id) ? ['condition_id' => $question_id] : []), // Legacy column name; contains the gating question id 'task' => $task, 'description' => $description, @@ -152,6 +159,7 @@ class department_selfserve_tasks_o extends db $this->department = new object_property($this->table, $this->id, 'department', 'int', false); $this->lane = new object_property($this->table, $this->id, 'lane', 'int', false); $this->product = new object_property($this->table, $this->id, 'product', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); $this->condition_id = new object_property($this->table, $this->id, 'condition_id', 'int', true); $this->task = new object_property($this->table, $this->id, 'task', 'string', false); $this->description = new object_property($this->table, $this->id, 'description', 'string', false); @@ -176,6 +184,7 @@ class department_selfserve_tasks_o extends db 'department' => (int)$this->department->value(), 'lane' => (int)$this->lane->value(), 'product' => (int)$this->product->value(), + 'machine_type_id' => is_null($this->machine_type_id->value()) ? null : (int)$this->machine_type_id->value(), 'condition_id' => is_null($this->condition_id->value()) ? null : (int)$this->condition_id->value(), 'task' => (string)$this->task->value(), 'description' => (string)$this->description->value(), @@ -209,6 +218,24 @@ class department_selfserve_tasks_o extends db $this->condition_id->set(is_null($question_id) ? null : (int)$question_id); } + public function getTasksForMachineType(int $machineTypeId): array + { + return self::getFieldsWhere([ + 'machine_type_id' => $machineTypeId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']); + } + + public function getLegacyTasksForLaneProduct(int $departmentId, int $laneId, int $productId): array + { + return self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'product' => $productId, + 'deleted_at' => null, + ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']); + } + /** * Normalize input for dynamic_images_vehicle_type into a nullable non-negative integer. * Accepts int, string (numeric), null, or empty string (treated as null). diff --git a/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php b/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php index 7b13c157..d1677123 100644 --- a/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php +++ b/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php @@ -104,4 +104,21 @@ class department_selfserve_vehicle_conditions_o extends db 'deleted_at' => is_null($this->deleted_at->value()) ? null : (string)$this->deleted_at->value(), ]; } -} \ No newline at end of file + + public function getAnswerMapForVehicle(int $departmentId, int $laneId, string $reg): array + { + $rows = self::getFieldsWhere([ + 'department' => $departmentId, + 'lane' => $laneId, + 'reg' => selfserve::standardize_registration($reg), + 'deleted_at' => null, + ], ['question', 'value']); + + $answers = []; + foreach ($rows as $row) { + $answers[(int)$row['question']] = (bool)$row['value']; + } + + return $answers; + } +} diff --git a/services/nginx/app/objects/selfserve_machine_types_o.php b/services/nginx/app/objects/selfserve_machine_types_o.php new file mode 100644 index 00000000..80d16510 --- /dev/null +++ b/services/nginx/app/objects/selfserve_machine_types_o.php @@ -0,0 +1,67 @@ +setTable('selfserve_machine_types'); + } + + public function add(string $name, ?string $description = null): self + { + global $db; + $name = $db->escape_string($name); + $description = $description === null ? null : $db->escape_string($description); + + $this->id = self::add_object([ + 'name' => $name, + 'description' => $description, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function getObjectProperties(): void + { + $this->name = new object_property($this->table, $this->id, 'name', 'string', false); + $this->description = new object_property($this->table, $this->id, 'description', 'string', 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 + { + // No dedicated cache invalidation yet. + } + + public function asArray(): array + { + return [ + 'id' => (int)$this->id, + 'name' => (string)$this->name->value(), + 'description' => $this->description->value() === null ? null : (string)$this->description->value(), + 'created_at' => (string)$this->created_at->value(), + 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/selfserve_wash_session_answers_o.php b/services/nginx/app/objects/selfserve_wash_session_answers_o.php new file mode 100644 index 00000000..9ed6f7b2 --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_session_answers_o.php @@ -0,0 +1,97 @@ +setTable('selfserve_wash_session_answers'); + } + + public function getObjectProperties(): void + { + $this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false); + $this->question_id = new object_property($this->table, $this->id, 'question_id', 'int', false); + $this->question_text = new object_property($this->table, $this->id, 'question_text', 'string', false); + $this->answer_value = new object_property($this->table, $this->id, 'answer_value', 'bool', false); + $this->answered_at = new object_property($this->table, $this->id, 'answered_at', 'datetime', 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 + { + // No dedicated cache invalidation yet. + } + + public function upsert(int $sessionId, int $questionId, string $questionText, bool $answerValue): self + { + $rows = $this->getFieldsWhere([ + 'session_id' => $sessionId, + 'question_id' => $questionId, + 'deleted_at' => null, + ], ['id']); + + if ($rows !== []) { + $this->select((int)$rows[0]['id']); + $this->question_text->set($questionText); + $this->answer_value->set($answerValue); + $this->answered_at->set(date('Y-m-d H:i:s')); + return $this; + } + + $this->id = self::add_object([ + 'session_id' => $sessionId, + 'question_id' => $questionId, + 'question_text' => $questionText, + 'answer_value' => $answerValue, + 'answered_at' => date('Y-m-d H:i:s'), + ]); + $this->getObjectProperties(); + $this->objectChanged(); + return $this; + } + + public function listBySession(int $sessionId): array + { + return $this->getFieldsWhere([ + 'session_id' => $sessionId, + 'deleted_at' => null, + ], ['id', 'question_id', 'question_text', 'answer_value', 'answered_at']); + } + + public function deleteMissingForSession(int $sessionId, array $questionIds): void + { + global $db; + + $sessionId = (int)$sessionId; + $questionIds = array_values(array_unique(array_map(static fn(mixed $value): int => (int)$value, $questionIds))); + + if ($questionIds === []) { + $db->query("DELETE FROM $this->table WHERE session_id = $sessionId"); + return; + } + + $questionIdsSql = implode(',', $questionIds); + $db->query("DELETE FROM $this->table WHERE session_id = $sessionId AND question_id NOT IN ($questionIdsSql)"); + } +} diff --git a/services/nginx/app/objects/selfserve_wash_session_events_o.php b/services/nginx/app/objects/selfserve_wash_session_events_o.php new file mode 100644 index 00000000..4442d48d --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_session_events_o.php @@ -0,0 +1,58 @@ +setTable('selfserve_wash_session_events'); + } + + public function getObjectProperties(): void + { + $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); + } + + public function objectChanged(): void + { + // No dedicated cache invalidation yet. + } + + public function add(int $sessionId, selfserve_wash_event_type $eventType, ?array $payload = null): self + { + $this->id = self::add_object([ + 'session_id' => $sessionId, + 'event_type' => $eventType->value, + 'payload_json' => $payload, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function listBySession(int $sessionId): array + { + return $this->getFieldsWhere([ + 'session_id' => $sessionId, + ], ['id', 'event_type', 'payload_json', 'created_at']); + } +} diff --git a/services/nginx/app/objects/selfserve_wash_session_tasks_o.php b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php new file mode 100644 index 00000000..3809af47 --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php @@ -0,0 +1,83 @@ +setTable('selfserve_wash_session_tasks'); + } + + public function getObjectProperties(): void + { + $this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false); + $this->task_id = new object_property($this->table, $this->id, 'task_id', 'int', false); + $this->task_text = new object_property($this->table, $this->id, 'task_text', 'string', false); + $this->description = new object_property($this->table, $this->id, 'description', 'string', false); + $this->services = new object_property($this->table, $this->id, 'services', 'json', false); + $this->buttons = new object_property($this->table, $this->id, 'buttons', '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 + { + // No dedicated cache invalidation yet. + } + + public function addSnapshot( + int $sessionId, + ?int $taskId, + string $taskText, + ?string $description = null, + ?array $services = null, + ?array $buttons = null + ): self { + $this->id = self::add_object([ + 'session_id' => $sessionId, + 'task_id' => $taskId, + 'task_text' => $taskText, + 'description' => $description, + 'services' => $services, + 'buttons' => $buttons, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function deleteBySession(int $sessionId): void + { + global $db; + $db->query("DELETE FROM $this->table WHERE session_id = " . (int)$sessionId); + } + + public function listBySession(int $sessionId): array + { + return $this->getFieldsWhere([ + 'session_id' => $sessionId, + 'deleted_at' => null, + ], ['id', 'task_id', 'task_text', 'description', 'services', 'buttons']); + } +} diff --git a/services/nginx/app/objects/selfserve_wash_sessions_o.php b/services/nginx/app/objects/selfserve_wash_sessions_o.php new file mode 100644 index 00000000..c8bd180e --- /dev/null +++ b/services/nginx/app/objects/selfserve_wash_sessions_o.php @@ -0,0 +1,193 @@ +setTable('selfserve_wash_sessions'); + } + + public function add( + int $laneId, + int $departmentId, + ?int $machineTypeId, + ?int $customerNumber, + string $reg, + ?int $vehicleId, + ?int $vehicleTypeId, + selfserve_wash_session_status $status, + bool $allowed = false, + ?array $metadata = null + ): self { + $this->id = self::add_object([ + 'lane_id' => $laneId, + 'department_id' => $departmentId, + 'machine_type_id' => $machineTypeId, + 'customer_number' => $customerNumber, + 'vehicle_id' => $vehicleId, + 'vehicle_type_id' => $vehicleTypeId, + 'reg' => selfserve::standardize_registration($reg), + 'status' => $status->value, + 'allowed' => $allowed, + 'metadata_json' => $metadata, + ]); + $this->getObjectProperties(); + $this->objectChanged(); + + return $this; + } + + public function getObjectProperties(): void + { + $this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int', false); + $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false); + $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); + $this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false); + $this->vehicle_id = new object_property($this->table, $this->id, 'vehicle_id', 'int', false); + $this->vehicle_type_id = new object_property($this->table, $this->id, 'vehicle_type_id', 'int', false); + $this->reg = new object_property($this->table, $this->id, 'reg', 'string', false); + $this->status = new object_property($this->table, $this->id, 'status', 'string', false); + $this->allowed = new object_property($this->table, $this->id, 'allowed', 'bool', false); + $this->machine_relay_enabled = new object_property($this->table, $this->id, 'machine_relay_enabled', 'bool', false); + $this->machine_relay_enabled_at = new object_property($this->table, $this->id, 'machine_relay_enabled_at', 'datetime', false); + $this->machine_start_triggered = new object_property($this->table, $this->id, 'machine_start_triggered', 'bool', false); + $this->machine_start_triggered_at = new object_property($this->table, $this->id, 'machine_start_triggered_at', 'datetime', false); + $this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', false); + $this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'datetime', 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 + { + // No dedicated cache invalidation yet. + } + + public function updateStatus(selfserve_wash_session_status $status): void + { + $this->status->set($status->value); + } + + public function markRelayEnabled(): void + { + $now = date('Y-m-d H:i:s'); + $this->machine_relay_enabled->set(true); + $this->machine_relay_enabled_at->set($now); + $this->status->set(selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value); + } + + public function markMachineStartTriggered(): void + { + $now = date('Y-m-d H:i:s'); + $this->machine_start_triggered->set(true); + $this->machine_start_triggered_at->set($now); + if ((bool)$this->machine_relay_enabled->value() !== true) { + $this->machine_relay_enabled->set(true); + $this->machine_relay_enabled_at->set($now); + } + $this->status->set(selfserve_wash_session_status::MACHINE_STARTED->value); + } + + public function markCompleted(?int $orderId = null): void + { + $this->completed_at->set(date('Y-m-d H:i:s')); + if ($orderId !== null) { + $this->order_id->set($orderId); + } + $this->status->set(selfserve_wash_session_status::COMPLETED->value); + } + + public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self + { + $filters = [ + 'lane_id' => $laneId, + 'reg' => selfserve::standardize_registration($reg), + 'completed_at' => null, + 'deleted_at' => null, + ]; + if ($customerNumber !== null) { + $filters['customer_number'] = $customerNumber; + } + $rows = $this->getFieldsWhere($filters, ['id']); + if ($rows === []) { + return $this; + } + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + + public function selectLatestByLaneAndReg(int $laneId, string $reg): self + { + $rows = $this->getFieldsWhere([ + 'lane_id' => $laneId, + 'reg' => selfserve::standardize_registration($reg), + 'deleted_at' => null, + ], ['id']); + if ($rows === []) { + return $this; + } + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + + public function asArray(): array + { + return [ + 'id' => (int)$this->id, + 'lane_id' => (int)$this->lane_id->value(), + 'department_id' => (int)$this->department_id->value(), + 'machine_type_id' => $this->machine_type_id->value() === null ? null : (int)$this->machine_type_id->value(), + 'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(), + 'vehicle_id' => $this->vehicle_id->value() === null ? null : (int)$this->vehicle_id->value(), + 'vehicle_type_id' => $this->vehicle_type_id->value() === null ? null : (int)$this->vehicle_type_id->value(), + 'reg' => (string)$this->reg->value(), + 'status' => (string)$this->status->value(), + 'allowed' => (bool)$this->allowed->value(), + 'machine_relay_enabled' => (bool)$this->machine_relay_enabled->value(), + 'machine_relay_enabled_at' => $this->machine_relay_enabled_at->value() === null ? null : (string)$this->machine_relay_enabled_at->value(), + 'machine_start_triggered' => (bool)$this->machine_start_triggered->value(), + 'machine_start_triggered_at' => $this->machine_start_triggered_at->value() === null ? null : (string)$this->machine_start_triggered_at->value(), + 'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(), + 'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->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(), + ]; + } +} diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index e3f3de0a..8c098ccd 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -56,6 +56,7 @@ class departmentLanesRoute 'relay_out_id', 'relay_machine_id', 'dynamic_image_id', + 'machine_type_id', ]) ->listObjectsWithPaginationIfSet( function ($department_lane) use ($user) { @@ -242,17 +243,25 @@ class departmentLanesRoute $relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; $relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; + $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; if ($dynamic_image_id !== null) { $did = (int)$dynamic_image_id; $this->requireType($did, $this->type_int()); $this->requireMinValue($did, 1); $dynamic_image_id = $did; } + if ($machine_type_id !== null && $machine_type_id !== '' && strtolower((string)$machine_type_id) !== 'null') { + $machine_type_id = (int)$machine_type_id; + $this->requireType($machine_type_id, $this->type_int()); + $this->requireMinValue($machine_type_id, 1); + } else { + $machine_type_id = null; + } // Remove spaces from the relay_in_id and relay_out_id // Check if the required fields are set if ($name && $department) { // Add the department lane - (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $dynamic_image_id); + (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $dynamic_image_id, $machine_type_id); // Return a success message $response->success('Department lane added'); } else { @@ -291,6 +300,7 @@ class departmentLanesRoute $relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; $relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; + $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; // Check what fields are set self::requireParameters(['id']); @@ -328,6 +338,17 @@ class departmentLanesRoute $department_lane->dynamic_image_id->set($did); } } + if (self::isParametersSet(['machine_type_id'])) { + $param = $machine_type_id; + if ($param === null || $param === '' || (is_string($param) && strtolower($param) === 'null')) { + $department_lane->machine_type_id->nullify(); + } else { + $machineTypeId = (int)$param; + $this->requireType($machineTypeId, $this->type_int()); + $this->requireMinValue($machineTypeId, 1); + $department_lane->machine_type_id->set($machineTypeId); + } + } // Return a success message $response->success('Department lane updated'); } else { @@ -342,4 +363,4 @@ class departmentLanesRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php index 31b39e10..3a8a6c95 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php @@ -39,7 +39,7 @@ class departmentSelfserveConditionRulesRoute $condition_o->select((int)$rules_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); if ($condition_o->exists()) { - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value()) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { $response->error('You do not have access to this department', 403); } } @@ -58,7 +58,7 @@ class departmentSelfserveConditionRulesRoute $condition_o->select($filters['condition_id']); if ($condition_o->exists()) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value()) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { $response->error('You do not have access to this department', 403); } } @@ -118,7 +118,7 @@ class departmentSelfserveConditionRulesRoute $response->error('Condition not found', 404); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -166,7 +166,7 @@ class departmentSelfserveConditionRulesRoute $condition_o = new department_selfserve_conditions_o(); $condition_o->select((int)$rule_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); - if ($condition_o->exists() && !in_array((int)$condition_o->department->value(), $authorized_department_ids)) { + if ($condition_o->exists() && !$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -177,7 +177,7 @@ class departmentSelfserveConditionRulesRoute if (!$new_condition_o->exists()) { $response->error('Target condition not found', 404); } - if (!in_array((int)$new_condition_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$new_condition_o->department->value())) { $response->error('You do not have access to the target department', 403); } $rule_o->condition_id->update($new_condition_id); @@ -230,7 +230,7 @@ class departmentSelfserveConditionRulesRoute $condition_o = new department_selfserve_conditions_o(); $condition_o->select((int)$rule_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); - if ($condition_o->exists() && !in_array((int)$condition_o->department->value(), $authorized_department_ids)) { + if ($condition_o->exists() && !$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -244,4 +244,9 @@ class departmentSelfserveConditionRulesRoute 'delete_department_selfserve_condition_rules' => 'Delete a department self-serve condition rule' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php index f41c5317..abbcc560 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php @@ -35,7 +35,7 @@ class departmentSelfserveConditionsRoute if (self::isParametersSet(['id'])) { $conditions_o->select((int)self::getParameter('id')); if ($conditions_o->exists()) { - if (!in_array((int)$conditions_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$conditions_o->department->value())) { if (!$has_view_all_permission) { $response->error('You do not have access to this department', 403); } @@ -49,15 +49,16 @@ class departmentSelfserveConditionsRoute $filters = []; if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids) && !$has_view_all_permission) { + if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { $response->error('You do not have access to this department', 403); } $filters['department'] = $requested_department; } else { if (!$has_view_all_permission) { if (empty($authorized_department_ids)) { - $response->success([]); - return; + $authorized_department_ids = [0]; + } else { + $authorized_department_ids[] = 0; } $filters['department'] = $authorized_department_ids; } @@ -71,8 +72,12 @@ class departmentSelfserveConditionsRoute $filters['product'] = (int)self::getParameter('product'); } + if (self::isParametersSet(['machine_type_id'])) { + $filters['machine_type_id'] = (int)self::getParameter('machine_type_id'); + } + $response->success( - $conditions_o->setSearchableFields(['id', 'department', 'lane', 'product', 'condition_id', 'name', 'description', 'deleted_at']) + $conditions_o->setSearchableFields(['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'deleted_at']) ->listObjectsWithPaginationIfSet(function ($condition) { $c = new department_selfserve_conditions_o(); $c->select((int)$condition['id']); @@ -95,20 +100,31 @@ class departmentSelfserveConditionsRoute $this->requirePermission('add_department_selfserve_conditions'); $user = (new authentication())->get_user(); if ($user) { - $department = (int)$response->getRequestParameter('department'); - $lane = (int)$response->getRequestParameter('lane'); - $product = (int)$response->getRequestParameter('product'); + $department = $response->isRequestParameterSet('department') ? (int)$response->getRequestParameter('department') : 0; + $lane = $response->isRequestParameterSet('lane') ? (int)$response->getRequestParameter('lane') : 0; + $product = $response->isRequestParameterSet('product') ? (int)$response->getRequestParameter('product') : 0; + $machine_type_id = null; + if ($response->isRequestParameterSet('machine_type_id')) { + $machine_type_param = $response->getRequestParameter('machine_type_id'); + if (!is_null($machine_type_param) && $machine_type_param !== '' && $machine_type_param !== 'null') { + $machine_type_id = (int)$machine_type_param; + } + } $condition_id = $response->getRequestParameter('condition_id'); // parent condition id for nested condition trees $condition_id = is_null($condition_id) || $condition_id === 'null' ? null : (int)$condition_id; $name = (string)$response->getRequestParameter('name'); $description = (string)$response->getRequestParameter('description'); - if (!$department || !$lane || !$product || !$name || !$description) { - $response->error('Missing required fields: department, lane, product, name, and description', 400); + if (!$name || !$description) { + $response->error('Missing required fields: name and description', 400); + } + + if ($machine_type_id === null && (!$department || !$lane || !$product)) { + $response->error('Missing required fields: either machine_type_id or department, lane, and product', 400); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, $department)) { $response->error('You do not have access to this department', 403); } @@ -119,7 +135,8 @@ class departmentSelfserveConditionsRoute $product, $name, $description, - $condition_id + $condition_id, + $machine_type_id ); (new logs_o())->add('department_selfserve_conditions', 'global', 1, $user->id, 'ADD_CONDITION', 'User added department self-serve condition ' . $condition_o->id); $response->success($condition_o->asArray()); @@ -153,13 +170,13 @@ class departmentSelfserveConditionsRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { $response->error('You do not have access to this department', 403); } if ($response->isRequestParameterSet('department')) { $new_department = (int)$response->getRequestParameter('department'); - if (!in_array($new_department, $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { $response->error('You do not have access to the target department', 403); } $condition_o->department->update($new_department); @@ -170,6 +187,10 @@ class departmentSelfserveConditionsRoute if ($response->isRequestParameterSet('product')) { $condition_o->product->update((int)$response->getRequestParameter('product')); } + if ($response->isRequestParameterSet('machine_type_id')) { + $machine_type_id = $response->getRequestParameter('machine_type_id'); + $condition_o->machine_type_id->update(is_null($machine_type_id) || $machine_type_id === '' || $machine_type_id === 'null' ? null : (int)$machine_type_id); + } if ($response->isRequestParameterSet('condition_id')) { $condition_id = $response->getRequestParameter('condition_id'); // parent condition id for nested condition trees $condition_o->condition_id->update(is_null($condition_id) || $condition_id === 'null' ? null : (int)$condition_id); @@ -210,7 +231,7 @@ class departmentSelfserveConditionsRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -224,4 +245,9 @@ class departmentSelfserveConditionsRoute 'delete_department_selfserve_conditions' => 'Delete a department self-serve condition' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveMachineTypesRoute.php b/services/nginx/app/routes/departmentSelfserveMachineTypesRoute.php new file mode 100644 index 00000000..557530bf --- /dev/null +++ b/services/nginx/app/routes/departmentSelfserveMachineTypesRoute.php @@ -0,0 +1,125 @@ +get('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('list_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $machine_types_o = new selfserve_machine_types_o(); + if (self::isParametersSet(['id'])) { + $machine_types_o->select((int)self::getParameter('id')); + if (!$machine_types_o->exists()) { + $response->error('Machine type not found', 404); + } + $response->success($machine_types_o->asArray()); + } + + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'LIST_MACHINE_TYPES', 'User listed self-serve machine types'); + $response->success( + $machine_types_o->setSearchableFields(['id', 'name', 'description', 'created_at', 'updated_at', 'deleted_at']) + ->listObjectsWithPaginationIfSet(function (array $machine_type): array { + return (new selfserve_machine_types_o())->select((int)$machine_type['id'])->asArray(); + }) + ); + }, [ + 'list_department_selfserve_machine_types' => 'List reusable self-serve machine types' + ]); + + $this->post('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('add_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $name = trim((string)$response->getRequestParameter('name')); + $description = $response->isRequestParameterSet('description') ? (string)$response->getRequestParameter('description') : null; + if ($name === '') { + $response->error('Missing required fields: name', 400); + } + + try { + $machine_type = (new selfserve_machine_types_o())->add($name, $description); + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'ADD_MACHINE_TYPE', 'User added self-serve machine type ' . $machine_type->id); + $response->success($machine_type->asArray()); + } catch (\Exception $e) { + $response->error($e->getMessage(), 500); + } + }, [ + 'add_department_selfserve_machine_types' => 'Add reusable self-serve machine types' + ]); + + $this->put('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('update_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['id']); + $machine_type = (new selfserve_machine_types_o())->select((int)self::getParameter('id')); + if (!$machine_type->exists()) { + $response->error('Machine type not found', 404); + } + + if (self::isParametersSet(['name'])) { + $name = trim((string)self::getParameter('name')); + if ($name === '') { + $response->error('name cannot be empty', 400); + } + $machine_type->name->set($name); + } + if (self::isParametersSet(['description'])) { + $description = self::getParameter('description'); + $machine_type->description->set($description === null || $description === '' ? null : (string)$description); + } + + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'UPDATE_MACHINE_TYPE', 'User updated self-serve machine type ' . $machine_type->id); + $response->success($machine_type->asArray()); + }, [ + 'update_department_selfserve_machine_types' => 'Update reusable self-serve machine types' + ]); + + $this->delete('/department/selfserve/machine-types', function () { + global $response; + $this->requirePermission('delete_department_selfserve_machine_types'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + self::requireParameters(['id']); + $machine_type = (new selfserve_machine_types_o())->select((int)self::getParameter('id')); + if (!$machine_type->exists()) { + $response->error('Machine type not found', 404); + } + + $machine_type->delete(); + (new logs_o())->add('selfserve_machine_types', 'global', 1, $user->id, 'DELETE_MACHINE_TYPE', 'User deleted self-serve machine type ' . $machine_type->id); + $response->success('Machine type deleted'); + }, [ + 'delete_department_selfserve_machine_types' => 'Delete reusable self-serve machine types' + ]); + } +} diff --git a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php index 8a8ed076..3603e35e 100644 --- a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php @@ -35,7 +35,7 @@ class departmentSelfserveQuestionsRoute if (self::isParametersSet(['id'])) { $questions_o->select((int)self::getParameter('id')); if ($questions_o->exists()) { - if (!in_array((int)$questions_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$questions_o->department->value())) { if (!$has_view_all_permission) { $response->error('You do not have access to this department', 403); } @@ -49,15 +49,16 @@ class departmentSelfserveQuestionsRoute $filters = []; if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids) && !$has_view_all_permission) { + if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { $response->error('You do not have access to this department', 403); } $filters['department'] = $requested_department; } else { if (!$has_view_all_permission) { if (empty($authorized_department_ids)) { - $response->success([]); - return; + $authorized_department_ids = [0]; + } else { + $authorized_department_ids[] = 0; } $filters['department'] = $authorized_department_ids; } @@ -95,20 +96,26 @@ class departmentSelfserveQuestionsRoute $this->requirePermission('add_department_selfserve_questions'); $user = (new authentication())->get_user(); if ($user) { - $department = (int)$response->getRequestParameter('department'); - $lane = (int)$response->getRequestParameter('lane'); - $product = (int)$response->getRequestParameter('product'); + $department = $response->isRequestParameterSet('department') ? (int)$response->getRequestParameter('department') : 0; + $lane = $response->isRequestParameterSet('lane') ? (int)$response->getRequestParameter('lane') : 0; + $product = $response->isRequestParameterSet('product') ? (int)$response->getRequestParameter('product') : 0; $question = (string)$response->getRequestParameter('question'); $description = (string)$response->getRequestParameter('description'); - $condition_id = $response->isRequestParameterSet('condition_id') ? (int)$response->getRequestParameter('condition_id') : null; // parent condition id that gates this question + $condition_id = null; + if ($response->isRequestParameterSet('condition_id')) { + $condition_id_param = $response->getRequestParameter('condition_id'); + if (!is_null($condition_id_param) && $condition_id_param !== '' && $condition_id_param !== 'null') { + $condition_id = (int)$condition_id_param; + } + } $order_priority = (int)($response->getRequestParameter('order_priority') ?? 0); - if (!$department || !$lane || !$product || !$question || !$description) { - $response->error('Missing required fields: department, lane, product, question, and description', 400); + if (!$question || !$description) { + $response->error('Missing required fields: question and description', 400); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, $department)) { $response->error('You do not have access to this department', 403); } @@ -152,13 +159,13 @@ class departmentSelfserveQuestionsRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$question_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$question_o->department->value())) { $response->error('You do not have access to this department', 403); } if (self::isParametersSet(['department'])) { $new_department = (int)self::getParameter('department'); - if (!in_array($new_department, $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { $response->error('You do not have access to the new department', 403); } $question_o->department->set($new_department); @@ -176,7 +183,8 @@ class departmentSelfserveQuestionsRoute $question_o->description->set((string)self::getParameter('description')); } if (self::isParametersSet(['condition_id'])) { - $question_o->condition_id->set(self::getParameter('condition_id') === null ? null : (int)self::getParameter('condition_id')); + $condition_id = self::getParameter('condition_id'); + $question_o->condition_id->set($condition_id === null || $condition_id === '' || $condition_id === 'null' ? null : (int)$condition_id); } if (self::isParametersSet(['order_priority'])) { $question_o->order_priority->set((int)self::getParameter('order_priority')); @@ -209,7 +217,7 @@ class departmentSelfserveQuestionsRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$question_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$question_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -223,4 +231,9 @@ class departmentSelfserveQuestionsRoute 'delete_department_selfserve_questions' => 'Delete a department self-serve question' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveTasksRoute.php b/services/nginx/app/routes/departmentSelfserveTasksRoute.php index 10d916ba..1bcfa1e5 100644 --- a/services/nginx/app/routes/departmentSelfserveTasksRoute.php +++ b/services/nginx/app/routes/departmentSelfserveTasksRoute.php @@ -39,7 +39,7 @@ class departmentSelfserveTasksRoute if (self::isParametersSet(['id'])) { $tasks_o->select((int)self::getParameter('id')); if ($tasks_o->exists()) { - if (!in_array((int)$tasks_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$tasks_o->department->value())) { if (!$has_view_all_permission) { $response->error('You do not have access to this department', 403); } @@ -53,15 +53,16 @@ class departmentSelfserveTasksRoute $filters = []; if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids) && !$has_view_all_permission) { + if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { $response->error('You do not have access to this department', 403); } $filters['department'] = $requested_department; } else { if (!$has_view_all_permission) { if (empty($authorized_department_ids)) { - $response->success([]); - return; + $authorized_department_ids = [0]; + } else { + $authorized_department_ids[] = 0; } $filters['department'] = $authorized_department_ids; } @@ -79,8 +80,12 @@ class departmentSelfserveTasksRoute $filters['condition_id'] = (int)self::getParameter('condition_id'); } + if (self::isParametersSet(['machine_type_id'])) { + $filters['machine_type_id'] = (int)self::getParameter('machine_type_id'); + } + $response->success( - $tasks_o->setSearchableFields(['id', 'department', 'lane', 'product', 'condition_id', 'task', 'description', 'deleted_at']) + $tasks_o->setSearchableFields(['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'task', 'description', 'deleted_at']) ->listObjectsWithPaginationIfSet(function ($task) { $t = new department_selfserve_tasks_o(); $t->select((int)$task['id']); @@ -103,10 +108,23 @@ class departmentSelfserveTasksRoute $this->requirePermission('add_department_selfserve_tasks'); $user = (new authentication())->get_user(); if ($user) { - $department = (int)$response->getRequestParameter('department'); - $lane = (int)$response->getRequestParameter('lane'); - $product = (int)$response->getRequestParameter('product'); - $question_id = $response->isRequestParameterSet('condition_id') ? (int)$response->getRequestParameter('condition_id') : null; // legacy request field: condition_id + $department = $response->isRequestParameterSet('department') ? (int)$response->getRequestParameter('department') : 0; + $lane = $response->isRequestParameterSet('lane') ? (int)$response->getRequestParameter('lane') : 0; + $product = $response->isRequestParameterSet('product') ? (int)$response->getRequestParameter('product') : 0; + $machine_type_id = null; + if ($response->isRequestParameterSet('machine_type_id')) { + $machine_type_param = $response->getRequestParameter('machine_type_id'); + if (!is_null($machine_type_param) && $machine_type_param !== '' && $machine_type_param !== 'null') { + $machine_type_id = (int)$machine_type_param; + } + } + $question_id = null; + if ($response->isRequestParameterSet('condition_id')) { + $condition_id_param = $response->getRequestParameter('condition_id'); + if (!is_null($condition_id_param) && $condition_id_param !== '' && $condition_id_param !== 'null') { + $question_id = (int)$condition_id_param; + } + } $task = (string)$response->getRequestParameter('task'); $description = (string)$response->getRequestParameter('description'); $order_priority = (int)($response->getRequestParameter('order_priority') ?? 0); @@ -167,12 +185,16 @@ class departmentSelfserveTasksRoute } } - if (!$department || !$lane || !$product || !$task || !$description) { - $response->error('Missing required fields: department, lane, product, task, and description', 400); + if (!$task || !$description) { + $response->error('Missing required fields: task and description', 400); + } + + if ($machine_type_id === null && (!$department || !$lane || !$product)) { + $response->error('Missing required fields: either machine_type_id or department, lane, and product', 400); } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, $department)) { $response->error('You do not have access to this department', 403); } @@ -188,6 +210,7 @@ class departmentSelfserveTasksRoute $services_enums, $buttons_ids, $vehicle_type, + $machine_type_id, ); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'ADD_TASK', 'User added a department self-serve task: ' . $task); $response->success($task_o->asArray()); @@ -219,13 +242,13 @@ class departmentSelfserveTasksRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { $response->error('You do not have access to this department', 403); } if (self::isParametersSet(['department'])) { $new_department = (int)self::getParameter('department'); - if (!in_array($new_department, $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { $response->error('You do not have access to the new department', 403); } $task_o->department->set($new_department); @@ -236,8 +259,13 @@ class departmentSelfserveTasksRoute if (self::isParametersSet(['product'])) { $task_o->product->set((int)self::getParameter('product')); } + if (self::isParametersSet(['machine_type_id'])) { + $param = self::getParameter('machine_type_id'); + $task_o->machine_type_id->set($param === null || $param === '' || $param === 'null' ? null : (int)$param); + } if (self::isParametersSet(['condition_id'])) { - $task_o->setQuestionId(self::getParameter('condition_id') === null ? null : (int)self::getParameter('condition_id')); + $param = self::getParameter('condition_id'); + $task_o->setQuestionId($param === null || $param === '' || $param === 'null' ? null : (int)$param); } if (self::isParametersSet(['task'])) { $task_o->task->set((string)self::getParameter('task')); @@ -343,7 +371,7 @@ class departmentSelfserveTasksRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -378,7 +406,7 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); $has_view_all_permission = $this->hasPermission('view_all_department_selfserve_tasks'); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { if (!$has_view_all_permission) { $response->error('You do not have access to this department', 403); } @@ -424,7 +452,7 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); $has_view_all_permission = $this->hasPermission('view_all_department_selfserve_tasks'); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { if (!$has_view_all_permission) { $response->error('You do not have access to this department', 403); } @@ -462,7 +490,7 @@ class departmentSelfserveTasksRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -507,7 +535,7 @@ class departmentSelfserveTasksRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$task_o->department->value(), $authorized_department_ids)) { + if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { $response->error('You do not have access to this department', 403); } @@ -522,4 +550,9 @@ class departmentSelfserveTasksRoute 'delete_department_selfserve_task_attachments' => 'Delete attachments for a department self-serve task' ]); } + + private function canAccessDepartment(array $authorizedDepartmentIds, int $departmentId): bool + { + return $departmentId === 0 || in_array($departmentId, $authorizedDepartmentIds, true); + } } diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index 4328d530..8f034630 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -8,8 +8,10 @@ namespace routes; use classes\authentication; use classes\response; use classes\selfserve; -use objects\department_selfserve_vehicle_conditions_o; +use modules\selfserve\classes\selfserve_wash_flow; use objects\customer_vehicles_o; +use objects\department_lanes_o; +use objects\department_selfserve_vehicle_conditions_o; use objects\logs_o; use traits\route_t; @@ -37,21 +39,19 @@ class departmentSelfserveVehicleConditionsRoute } (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'LIST_VEHICLE_CONDITIONS', 'User listed department self-serve vehicle conditions'); - + $conditions_o = new department_selfserve_vehicle_conditions_o(); $customer_number = (int)$user->customer_number->value(); - - // If an ID is provided, return that specific condition + if (self::isParametersSet(['id'])) { $conditions_o->select((int)self::getParameter('id')); if ($conditions_o->exists()) { if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$conditions_o->department->value(), $authorized_department_ids)) { + if (!in_array((int)$conditions_o->department->value(), $authorized_department_ids, true)) { $response->error('You do not have access to this department', 403); } } else { - // Own permission only if ((int)$conditions_o->customer_id->value() !== $customer_number) { $response->error('You do not have access to this condition', 403); } @@ -67,7 +67,7 @@ class departmentSelfserveVehicleConditionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); - if (!in_array($requested_department, $authorized_department_ids)) { + if (!in_array($requested_department, $authorized_department_ids, true)) { $response->error('You do not have access to this department', 403); } $filters['department'] = $requested_department; @@ -79,7 +79,6 @@ class departmentSelfserveVehicleConditionsRoute $filters['customer_id'] = (int)self::getParameter('customer_id'); } } else { - // Own permission only $filters['customer_id'] = $customer_number; if (self::isParametersSet(['department'])) { $filters['department'] = (int)self::getParameter('department'); @@ -91,26 +90,104 @@ class departmentSelfserveVehicleConditionsRoute } if (self::isParametersSet(['reg'])) { - $filters['reg'] = (string)self::getParameter('reg'); + $filters['reg'] = selfserve::standardize_registration((string)self::getParameter('reg')); } if (self::isParametersSet(['question'])) { $filters['question'] = (int)self::getParameter('question'); } - $response->success( - $conditions_o->setSearchableFields(['id', 'department', 'lane', 'customer_id', 'reg', 'question', 'value', 'created_at', 'updated_at', 'deleted_at']) - ->listObjectsWithPaginationIfSet(function ($condition) { - $c = new department_selfserve_vehicle_conditions_o(); - $c->select((int)$condition['id']); - return $c->asArray(); - }, $conditions_o->forceRestrictFilters($filters)) - ); + $response->success( + $conditions_o->setSearchableFields(['id', 'department', 'lane', 'customer_id', 'reg', 'question', 'value', 'created_at', 'updated_at', 'deleted_at']) + ->listObjectsWithPaginationIfSet(function ($condition) { + $c = new department_selfserve_vehicle_conditions_o(); + $c->select((int)$condition['id']); + return $c->asArray(); + }, $conditions_o->forceRestrictFilters($filters)) + ); }, [ 'list_department_selfserve_vehicle_conditions' => 'List all department self-serve vehicle conditions', 'list_own_department_selfserve_vehicle_conditions' => 'List own department self-serve vehicle conditions' ]); + /** + * Check whether self-serve is allowed for a specific vehicle and lane + */ + $this->get('/department/selfserve/vehicle/allowed', function () { + global $response; + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions'); + $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); + if (!$has_global && !$has_own) { + $response->error('Permission denied', 403); + } + + self::requireParameters(['lane_id', 'reg']); + $lane_id = (int)self::getParameter('lane_id'); + $reg = selfserve::standardize_registration((string)self::getParameter('reg')); + + $lane = $this->assertLaneAccess($user, $lane_id, $has_global); + $customer_number = null; + if (!$has_global && $has_own) { + $vehicle = $this->assertOwnVehicle($user, $reg); + $customer_number = (int)$vehicle->customer_id->value(); + } + + (new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg); + $response->success($this->getWashFlow()->previewVehicleEligibility($lane_id, $reg, $customer_number)); + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle', + 'list_own_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for an owned vehicle' + ]); + + /** + * Get self-serve wash summary + */ + $this->get('/department/selfserve/washes/summary', function () { + global $response; + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions'); + $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); + if (!$has_global && !$has_own) { + $response->error('Permission denied', 403); + } + + $flow = $this->getWashFlow(); + try { + if (self::isParametersSet(['session_id'])) { + $summary = $flow->getSessionSummary((int)self::getParameter('session_id')); + $this->assertSummaryAccess($user, $summary, $has_global); + $response->success($summary); + } + + self::requireParameters(['lane_id', 'reg']); + $lane_id = (int)self::getParameter('lane_id'); + $reg = selfserve::standardize_registration((string)self::getParameter('reg')); + + $this->assertLaneAccess($user, $lane_id, $has_global); + if (!$has_global && $has_own) { + $this->assertOwnVehicle($user, $reg); + } + + $summary = $flow->getLatestSessionSummary($lane_id, $reg); + $this->assertSummaryAccess($user, $summary, $has_global); + $response->success($summary); + } catch (\RuntimeException $e) { + $response->error($e->getMessage(), 404); + } + }, [ + 'list_department_selfserve_vehicle_conditions' => 'View self-serve wash summaries', + 'list_own_department_selfserve_vehicle_conditions' => 'View self-serve wash summaries for owned vehicles' + ]); + /** * Add a department self-serve vehicle condition */ @@ -130,7 +207,7 @@ class departmentSelfserveVehicleConditionsRoute $department = (int)$response->getRequestParameter('department'); $lane = (int)$response->getRequestParameter('lane'); - $reg = (string)$response->getRequestParameter('reg'); + $reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg')); $question = (int)$response->getRequestParameter('question'); $value = (bool)$response->getRequestParameter('value'); @@ -141,24 +218,24 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $customer_id = $response->isRequestParameterSet('customer_id') ? (int)$response->getRequestParameter('customer_id') : null; $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($department, $authorized_department_ids)) { + if (!in_array($department, $authorized_department_ids, true)) { $response->error('You do not have access to this department', 403); } } else { - // Own permission only $customer_id = (int)$user->customer_number->value(); - // Verify vehicle ownership - $vehicle_o = (new customer_vehicles_o())->selectByPlate($reg); - if (!$vehicle_o->exists() || (int)$vehicle_o->customer_id->value() !== $customer_id) { - $response->error('You do not own this vehicle', 403); - } + $vehicle_o = $this->assertOwnVehicle($user, $reg); + $customer_id = (int)$vehicle_o->customer_id->value(); } try { $condition_o = new department_selfserve_vehicle_conditions_o(); $condition_o->add($department, $lane, $reg, $question, $value, $customer_id); + $summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id); (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id); - $response->success($condition_o->asArray()); + $response->success([ + 'condition' => $condition_o->asArray(), + 'selfserve' => $summary, + ]); } catch (\Exception $e) { $response->error($e->getMessage(), 500); } @@ -199,11 +276,10 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { + if (!in_array((int)$condition_o->department->value(), $authorized_department_ids, true)) { $response->error('You do not have access to this department', 403); } } else { - // Own permission only if ((int)$condition_o->customer_id->value() !== $customer_number) { $response->error('You do not have access to this condition', 403); } @@ -213,7 +289,7 @@ class departmentSelfserveVehicleConditionsRoute $new_department = (int)$response->getRequestParameter('department'); if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array($new_department, $authorized_department_ids)) { + if (!in_array($new_department, $authorized_department_ids, true)) { $response->error('You do not have access to the target department', 403); } } @@ -225,9 +301,8 @@ class departmentSelfserveVehicleConditionsRoute if ($response->isRequestParameterSet('reg')) { $new_reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg')); if (!$has_global && $has_own) { - // Verify ownership of the new reg - $vehicle_o = (new customer_vehicles_o())->selectByPlate($new_reg); - if (!$vehicle_o->exists() || (int)$vehicle_o->customer_id->value() !== $customer_number) { + $vehicle_o = $this->assertOwnVehicle($user, $new_reg); + if ((int)$vehicle_o->customer_id->value() !== $customer_number) { $response->error('You do not own this vehicle', 403); } } @@ -247,8 +322,20 @@ class departmentSelfserveVehicleConditionsRoute $condition_o->customer_id->update($new_customer_id); } - (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'UPDATE_VEHICLE_CONDITION', 'User updated department self-serve vehicle condition ' . $id); - $response->success($condition_o->asArray()); + try { + $summary = $this->getWashFlow()->synchronizeSession( + (int)$condition_o->lane->value(), + (string)$condition_o->reg->value(), + $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value() + ); + (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'UPDATE_VEHICLE_CONDITION', 'User updated department self-serve vehicle condition ' . $id); + $response->success([ + 'condition' => $condition_o->asArray(), + 'selfserve' => $summary, + ]); + } catch (\Exception $e) { + $response->error($e->getMessage(), 500); + } }, [ 'update_department_selfserve_vehicle_conditions' => 'Update a department self-serve vehicle condition', 'update_own_department_selfserve_vehicle_conditions' => 'Update own department self-serve vehicle condition' @@ -284,22 +371,98 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$condition_o->department->value(), $authorized_department_ids)) { + if (!in_array((int)$condition_o->department->value(), $authorized_department_ids, true)) { $response->error('You do not have access to this department', 403); } } else { - // Own permission only if ((int)$condition_o->customer_id->value() !== (int)$user->customer_number->value()) { $response->error('You do not have access to this condition', 403); } } + $lane_id = (int)$condition_o->lane->value(); + $reg = (string)$condition_o->reg->value(); + $customer_id = $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(); + $condition_o->delete(); + + try { + $summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id); + } catch (\Throwable) { + $summary = null; + } + (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'DELETE_VEHICLE_CONDITION', 'User deleted department self-serve vehicle condition ' . $id); - $response->success('Condition deleted'); + $response->success([ + 'message' => 'Condition deleted', + 'selfserve' => $summary, + ]); }, [ 'delete_department_selfserve_vehicle_conditions' => 'Delete a department self-serve vehicle condition', 'delete_own_department_selfserve_vehicle_conditions' => 'Delete own department self-serve vehicle condition' ]); } + + private function getWashFlow(): selfserve_wash_flow + { + return new selfserve_wash_flow(); + } + + private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o + { + global $response; + + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + $response->error('Department lane not found', 404); + } + + if ($hasGlobalPermission) { + $authorized_department_ids = $user->getGroup()->getDepartments(); + if (!in_array((int)$lane->department->value(), $authorized_department_ids, true)) { + $response->error('You do not have access to this department', 403); + } + } + + return $lane; + } + + private function assertOwnVehicle(object $user, string $reg): customer_vehicles_o + { + global $response; + + $vehicle_o = (new customer_vehicles_o())->selectByPlate($reg); + if (!$vehicle_o->exists() || (int)$vehicle_o->customer_id->value() !== (int)$user->customer_number->value()) { + $response->error('You do not own this vehicle', 403); + } + + return $vehicle_o; + } + + private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission): void + { + global $response; + + if ($hasGlobalPermission) { + $authorized_department_ids = $user->getGroup()->getDepartments(); + $lane_department = (int)($summary['lane']['department'] ?? 0); + if (!in_array($lane_department, $authorized_department_ids, true)) { + $response->error('You do not have access to this department', 403); + } + return; + } + + $session_customer_number = $summary['session']['customer_number'] ?? null; + if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) { + return; + } + + $reg = (string)($summary['session']['reg'] ?? ''); + $vehicle_o = (new customer_vehicles_o())->selectByPlate($reg); + if ($vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value()) { + return; + } + + $response->error('You do not have access to this wash summary', 403); + } } diff --git a/services/nginx/app/routes/machineButtonPressRoute.php b/services/nginx/app/routes/machineButtonPressRoute.php index 159bdc40..260ddaa8 100644 --- a/services/nginx/app/routes/machineButtonPressRoute.php +++ b/services/nginx/app/routes/machineButtonPressRoute.php @@ -3,12 +3,11 @@ namespace routes; use classes\authentication; -use classes\slack; -use objects\customer_vehicles_o; +use classes\selfserve; +use modules\selfserve\classes\selfserve_wash_flow; +use objects\department_lanes_o; use objects\logs_o; -use objects\orders_o; -use objects\plate_scans_o; -use objects\users_o; +use objects\plate_scanners_o; use traits\route_t; class machineButtonPressRoute @@ -17,28 +16,64 @@ class machineButtonPressRoute public function run(): void { - - self::get('/relay/button/press/post', function () { - /** - * This is here because the post method is not supported by the software some of the cameras are running on. - */ + $handler = function () { global $response; + self::requirePlateScannerAuth(); $plate_scanner = (new authentication())->get_plate_scanner(); - self::requireParameters(['token']); - // Validate the token - self::requireType('token', 'string'); - self::requireMinLength('token', 1); - self::requireMaxLength('token', 100); - // TODO: Implement action - //$slack = new slack(); - //$slack->send_department_booking_notification((int)$plate_scanner->department_id->value(), 'Button was pressed on the machine with the plate scanner: ' . $plate_scanner->id); - // Log the incident - (new logs_o())->add('relay', $plate_scanner->department_id->value(), 1, 0, 'TRIGGER_BUTTON_PRESS', 'Button was pressed on the machine with the plate scanner: ' . $plate_scanner->id); - // Return a success message - $response->success(['message' => 'Button press recorded.', 'scanner' => $plate_scanner->name->value()], 201); - }, [ + if (!$plate_scanner) { + $response->error('Invalid plate scanner session', 403); + } + + $lane_id = $this->resolveLaneId($plate_scanner); + $reg = self::isParametersSet(['reg']) ? selfserve::standardize_registration((string)self::getParameter('reg')) : null; + $payload = $this->getParametersAsArray(); + unset($payload['token']); + + try { + $summary = (new selfserve_wash_flow())->recordMachineStartWebhook($lane_id, $reg, $payload); + } catch (\RuntimeException $e) { + $response->error($e->getMessage(), 404); + } + + (new logs_o())->add('relay', $plate_scanner->department_id->value(), 1, 0, 'TRIGGER_BUTTON_PRESS', 'Button was pressed on the machine with the plate scanner: ' . $plate_scanner->id . ' for lane ' . $lane_id); + $response->success([ + 'message' => 'Button press recorded.', + 'scanner' => $plate_scanner->name->value(), + 'lane_id' => $lane_id, + 'selfserve' => $summary, + ], 201); + }; + + $this->get('/relay/button/press/post', $handler, [ + 'add_button_press' => 'Add a button press' + ]); + + $this->post('/relay/button/press/post', $handler, [ 'add_button_press' => 'Add a button press' ]); } -} \ No newline at end of file + + private function resolveLaneId(plate_scanners_o $plateScanner): int + { + global $response; + + if (self::isParametersSet(['lane_id'])) { + $lane = (new department_lanes_o())->select((int)self::getParameter('lane_id')); + if (!$lane->exists()) { + $response->error('Department lane not found', 404); + } + if ((int)$lane->department->value() !== (int)$plateScanner->department_id->value()) { + $response->error('The lane does not belong to the plate scanner department', 403); + } + return (int)$lane->id; + } + + $lanes = (new department_lanes_o())->getDepartmentLanes((int)$plateScanner->department_id->value()); + if (count($lanes) === 1) { + return (int)$lanes[0]->id; + } + + $response->error('lane_id is required when the department has multiple self-serve lanes', 400); + } +} diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index 063d7faa..ce510f13 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -8,6 +8,7 @@ use classes\economic; use classes\email; use classes\fxratesapi; use classes\motorapi; +use classes\n8n; use classes\recaptcha; use classes\response; use classes\router; @@ -452,6 +453,45 @@ class moduleConfigRoute ] ); + /** n8n config > GET */ + $this->get('/n8n/config', function () { + global $response; + $this->requirePermission('modules_n8n_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('n8n_config', 'global', 1, $user->id, 'N8N_CONFIG', 'Successfully fetched n8n config'); + $response->success( + (new n8n())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('n8n_config', 'global', 1, 0, 'N8N_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_n8n_config' => 'Get n8n config' + ] + ); + /** n8n config > POST */ + $this->post('/n8n/config', function () { + global $response; + $this->requirePermission('modules_n8n_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('n8n_config', 'global', 1, $user->id, 'N8N_CONFIG', 'Successfully updated n8n config'); + $response->success( + (new n8n())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('n8n_config', 'global', 1, 0, 'N8N_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_n8n_config' => 'Update n8n config' + ] + ); + /** XLVask config > GET */ $this->get('/xlvask/config', function () { global $response; diff --git a/services/nginx/app/routes/moduleN8nRoute.php b/services/nginx/app/routes/moduleN8nRoute.php new file mode 100644 index 00000000..0725ac37 --- /dev/null +++ b/services/nginx/app/routes/moduleN8nRoute.php @@ -0,0 +1,351 @@ +get('/modules/n8n/workflows', function () { + global $response; + self::requirePermission('modules_n8n_workflows_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'active', + 'tags', + 'name', + 'projectId', + 'excludePinnedData', + 'limit', + 'cursor', + ]); + + $result = (new n8n())->listWorkflows($filters); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOWS_LIST', 'Listed n8n workflows'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_view' => 'List n8n workflows', + ]); + + $this->get('/modules/n8n/workflows/{id}', function () { + global $response; + self::requirePermission('modules_n8n_workflows_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $workflowId = (string)$this->fromRoute('id'); + $result = (new n8n())->getWorkflow($workflowId, $this->toBool($this->fromQuery('excludePinnedData'))); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_GET', 'Fetched n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_view' => 'Get a specific n8n workflow', + ]); + + $this->post('/modules/n8n/workflows', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(); + $workflow = $this->extractWorkflowPayload($body); + + $result = (new n8n())->createWorkflow($workflow); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_CREATE', 'Created n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Create n8n workflows', + ]); + + $this->put('/modules/n8n/workflows/{id}', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(); + $workflow = $this->extractWorkflowPayload($body); + + $result = (new n8n())->updateWorkflow((string)$this->fromRoute('id'), $workflow); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_UPDATE', 'Updated n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Update n8n workflows', + ]); + + $this->post('/modules/n8n/workflows/{id}/publish', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(false); + $options = $body !== null ? $this->filterObjectProperties($body, ['versionId', 'name', 'description']) : null; + + $result = (new n8n())->publishWorkflow((string)$this->fromRoute('id'), $options); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_PUBLISH', 'Published n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Publish n8n workflows', + ]); + + $this->post('/modules/n8n/workflows/{id}/deactivate', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new n8n())->deactivateWorkflow((string)$this->fromRoute('id')); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WORKFLOW_DEACTIVATE', 'Deactivated n8n workflow'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Deactivate n8n workflows', + ]); + + $this->post('/modules/n8n/webhooks/trigger', function () { + global $response; + self::requirePermission('modules_n8n_workflows_run'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(); + $webhookTarget = $this->extractWebhookTarget($body); + $payload = property_exists($body, 'payload') ? $body->payload : null; + $query = property_exists($body, 'query') ? $this->objectToArray($body->query) : []; + $method = $this->normalizeHttpMethod(property_exists($body, 'method') ? (string)$body->method : 'POST'); + + $result = (new n8n())->runWebhook($webhookTarget, $payload, $method, $query); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_WEBHOOK_TRIGGER', 'Triggered n8n webhook'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_run' => 'Trigger n8n workflows through webhooks', + ]); + + $this->get('/modules/n8n/executions', function () { + global $response; + self::requirePermission('modules_n8n_executions_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'includeData', + 'status', + 'workflowId', + 'projectId', + 'limit', + 'cursor', + ]); + + $result = (new n8n())->listExecutions($filters); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTIONS_LIST', 'Listed n8n executions'); + $response->success($result, 200); + }, [ + 'modules_n8n_executions_view' => 'List n8n executions', + ]); + + $this->get('/modules/n8n/executions/{id}', function () { + global $response; + self::requirePermission('modules_n8n_executions_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new n8n())->getExecution((int)$this->fromRoute('id'), $this->toBool($this->fromQuery('includeData'))); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTION_GET', 'Fetched n8n execution'); + $response->success($result, 200); + }, [ + 'modules_n8n_executions_view' => 'Get a specific n8n execution', + ]); + + $this->post('/modules/n8n/executions/{id}/retry', function () { + global $response; + self::requirePermission('modules_n8n_workflows_run'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $body = $this->readJsonBody(false); + $loadWorkflow = $body !== null && property_exists($body, 'loadWorkflow') + ? $this->toBool($body->loadWorkflow) + : false; + + $result = (new n8n())->retryExecution((int)$this->fromRoute('id'), $loadWorkflow); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTION_RETRY', 'Retried n8n execution'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_run' => 'Retry n8n executions', + ]); + + $this->post('/modules/n8n/executions/{id}/stop', function () { + global $response; + self::requirePermission('modules_n8n_workflows_manage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new n8n())->stopExecution((int)$this->fromRoute('id')); + (new logs_o())->add('modules_n8n', 'global', 1, $user->id, 'MODULES_N8N_EXECUTION_STOP', 'Stopped n8n execution'); + $response->success($result, 200); + }, [ + 'modules_n8n_workflows_manage' => 'Stop running n8n executions', + ]); + } + + private function readJsonBody(bool $required = true): ?object + { + global $response; + + $raw = file_get_contents('php://input'); + if ($raw === false || trim($raw) === '') { + if ($required) { + $response->error('Request body must contain valid JSON.', 400); + } + + return null; + } + + $decoded = json_decode($raw); + if (json_last_error() !== JSON_ERROR_NONE || !is_object($decoded)) { + $response->error('Request body must contain valid JSON.', 400); + } + + return $decoded; + } + + private function extractWorkflowPayload(object $body): object + { + global $response; + + $workflow = property_exists($body, 'workflow') && is_object($body->workflow) + ? $body->workflow + : $body; + + if (!property_exists($workflow, 'name') && !property_exists($workflow, 'nodes') && !property_exists($workflow, 'connections')) { + $response->error('Workflow payload is missing. Provide a workflow object or workflow fields in the request body.', 400); + } + + return $workflow; + } + + private function extractWebhookTarget(object $body): string + { + global $response; + + foreach (['webhook_url', 'webhookUrl', 'webhook_path', 'webhookPath'] as $field) { + if (property_exists($body, $field) && is_string($body->{$field}) && trim($body->{$field}) !== '') { + return trim($body->{$field}); + } + } + + $response->error('Provide webhook_url or webhook_path to trigger an n8n workflow.', 400); + } + + private function normalizeHttpMethod(string $method): string + { + $normalized = strtoupper(trim($method)); + if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return 'POST'; + } + + return $normalized; + } + + private function filterRequestParameters(array $parameters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($parameters as $key => $value) { + if (isset($allowed[$key]) && $value !== '' && $value !== null) { + $filtered[$key] = $value; + } + } + + return $filtered; + } + + private function filterObjectProperties(object $source, array $allowedKeys): object + { + $filtered = new stdClass(); + + foreach ($allowedKeys as $key) { + if (property_exists($source, $key) && $source->{$key} !== null && $source->{$key} !== '') { + $filtered->{$key} = $source->{$key}; + } + } + + return $filtered; + } + + private function objectToArray(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_object($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); + if ($encoded !== false) { + $decoded = json_decode($encoded, true); + if (is_array($decoded)) { + return $decoded; + } + } + } + + return []; + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + return in_array($normalized, ['1', 'true', 'yes', 'on'], true); + } + + if (is_int($value)) { + return $value === 1; + } + + return false; + } +} diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php new file mode 100644 index 00000000..88c1ac6f --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php @@ -0,0 +1,263 @@ +fixedVersion; + } + + public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array + { + return $this->subscriptionVersions; + } + + public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array + { + return null; + } + + public function runBestEffortBackfill(): array + { + $this->backfillCalls++; + return [ + 'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0], + 'warnings' => [], + ]; + } + } +} + +if (!class_exists('TestableEconomicV2DistributionService')) { + class TestableEconomicV2DistributionService extends economic_v2_distribution_service + { + public array $stubOrders = []; + public array $stubOrderItems = []; + public array $stubVersionRows = []; + public array $subscriptionPrices = []; + public array $versionTableState = []; + + public function __construct(?economic_v2_versioning_service $versioning = null) + { + parent::__construct($versioning); + } + + protected function fetchOrdersInRange(string $from_ts, string $to_ts): array + { + return $this->stubOrders; + } + + protected function fetchOrderItemsByOrderIds(array $order_ids): array + { + return $this->stubOrderItems; + } + + protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array + { + return $this->stubVersionRows; + } + + protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float + { + $total = 0.0; + foreach ($order_items as $item) { + $total += (float)($item['price'] ?? 0) * (float)($item['quantity'] ?? 0); + } + return $total; + } + + protected function getSubscriptionMonthlyPrice(int $vehicle_type): float + { + return (float)($this->subscriptionPrices[$vehicle_type] ?? 0.0); + } + + protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array + { + return [ + 'id' => $customer_number, + 'customer_number' => $customer_number, + 'customer_name' => 'Customer ' . $customer_number, + 'transactions' => array_values($transaction_map), + 'requires_action' => false, + 'meta' => [], + ]; + } + + protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): array + { + return [ + 'id' => $order_id, + 'date' => $created_at, + 'amount' => round((float)($amount ?? 0.0), 5), + 'booked' => true, + 'department_id' => $department_id, + 'excluded' => !$this->isDepartmentEligible($department_id), + ]; + } + + protected function isDepartmentEligible(int $department_id): bool + { + return $department_id > 0 && $department_id !== 10; + } + + protected function parseDepartmentMap(array $department_map): array + { + $parsed = []; + foreach ($department_map as $department_id => $amount) { + $parsed['Department ' . $department_id] = round((float)$amount, 5); + } + return $parsed; + } + + protected function versionTableHasRows(string $table): bool + { + if (array_key_exists($table, $this->versionTableState)) { + return (bool)$this->versionTableState[$table]; + } + + return true; + } + } +} + +it('runs best-effort backfill when fixed pricing version history is empty', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + $versioning->fixedVersion = [ + 'id' => 91, + 'price' => 499.95, + 'description' => 'Fixed pricing system order', + 'source' => 'backfill.inferred_fixed_pricing_order', + 'confidence' => 0.55, + 'inferred' => true, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + + $service = new TestableEconomicV2DistributionService($versioning); + $service->versionTableState = [ + 'customer_fixed_pricing_versions' => false, + ]; + $service->stubOrders = [[ + 'id' => 501, + 'customer_id' => 12345, + 'department_id' => 10, + 'created_at' => '2026-01-15 10:00:00', + 'reg_1' => '', + 'reference' => 'Fast pris aftale', + ]]; + $service->stubOrderItems = [ + 501 => [[ + 'product_id' => 61, + 'price' => 499.95, + 'quantity' => 1, + 'reference' => '', + ]], + ]; + + $result = $service->getFixedPricingDistribution('2026-01-01', '2026-01-31'); + + expect($versioning->backfillCalls)->toBe(1); + expect($result['customers'])->toHaveCount(1); +}); + +it('falls back to system orders for fixed pricing when regular orders yield no customers', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + $versioning->fixedVersion = [ + 'id' => 91, + 'price' => 499.95, + 'description' => 'Fixed pricing system order', + 'source' => 'backfill.inferred_fixed_pricing_order', + 'confidence' => 0.55, + 'inferred' => true, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + + $service = new TestableEconomicV2DistributionService($versioning); + $service->stubOrders = [[ + 'id' => 501, + 'customer_id' => 12345, + 'department_id' => 10, + 'created_at' => '2026-01-15 10:00:00', + 'reg_1' => '', + 'reference' => 'Fast pris aftale', + ]]; + $service->stubOrderItems = [ + 501 => [[ + 'product_id' => 61, + 'price' => 499.95, + 'quantity' => 1, + 'reference' => '', + ]], + ]; + + $result = $service->getFixedPricingDistribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['customer_number'])->toBe(12345); + expect($result['customers'][0]['meta']['fixed_pricing']['price'])->toBe(499.95); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['order_ids'])->toBe([501]); + expect($result['collective_results']['total_fixed_price'])->toBe(499.95); + expect($result['warnings'])->toContain('System order fallback used for fixed pricing (department 10).'); +}); + +it('falls back to system orders for wash subscriptions when regular orders yield no customers', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + $versioning->subscriptionVersions = [[ + 'id' => 42, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'source' => 'backfill.inferred_subscription_order', + 'confidence' => 0.5, + 'inferred' => true, + ]]; + + $service = new TestableEconomicV2DistributionService($versioning); + $service->subscriptionPrices = [ + 77 => 299.0, + ]; + $service->stubOrders = [[ + 'id' => 601, + 'customer_id' => 12345, + 'department_id' => 10, + 'created_at' => '2026-01-15 10:00:00', + 'reg_1' => '', + 'reference' => 'Vaskeabonnementer', + ]]; + $service->stubOrderItems = [ + 601 => [[ + 'product_id' => 77, + 'price' => 299.0, + 'quantity' => 1, + 'reference' => 'AB12345', + ]], + ]; + + $result = $service->getWashSubscriptionsDistribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['customer_number'])->toBe(12345); + expect($result['customers'][0]['meta']['subscription']['subscription_total'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['reg'])->toBe('AB12345'); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['10'])->toBe(299.0); + expect($result['collective_results']['total_subscription_price'])->toBe(299.0); + expect($result['warnings'])->toContain('System order fallback used for wash subscriptions (department 10).'); +}); diff --git a/services/nginx/app/tests/Unit/N8n/N8nRouteHelpersTest.php b/services/nginx/app/tests/Unit/N8n/N8nRouteHelpersTest.php new file mode 100644 index 00000000..fe90ea66 --- /dev/null +++ b/services/nginx/app/tests/Unit/N8n/N8nRouteHelpersTest.php @@ -0,0 +1,46 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +it('normalizes webhook methods and falls back to POST for unsupported verbs', function (): void { + $_SERVER['REQUEST_URI'] = '/modules/n8n/webhooks/trigger'; + $route = new moduleN8nRoute(); + + expect(n8n_route_invoke_private($route, 'normalizeHttpMethod', ['patch']))->toBe('PATCH'); + expect(n8n_route_invoke_private($route, 'normalizeHttpMethod', [' delete ']))->toBe('DELETE'); + expect(n8n_route_invoke_private($route, 'normalizeHttpMethod', ['trace']))->toBe('POST'); +}); + +it('filters request parameters down to the allowed n8n query keys', function (): void { + $_SERVER['REQUEST_URI'] = '/modules/n8n/workflows'; + $route = new moduleN8nRoute(); + + $filtered = n8n_route_invoke_private($route, 'filterRequestParameters', [[ + 'active' => 'true', + 'limit' => '25', + 'cursor' => '', + 'projectId' => 'abc123', + 'ignored' => 'value', + ], [ + 'active', + 'limit', + 'projectId', + ]]); + + expect($filtered)->toBe([ + 'active' => 'true', + 'limit' => '25', + 'projectId' => 'abc123', + ]); +}); diff --git a/services/nginx/app/tests/Unit/N8n/N8nRouteWiringTest.php b/services/nginx/app/tests/Unit/N8n/N8nRouteWiringTest.php new file mode 100644 index 00000000..48ba50b9 --- /dev/null +++ b/services/nginx/app/tests/Unit/N8n/N8nRouteWiringTest.php @@ -0,0 +1,27 @@ +not->toBeFalse(); + expect($content)->toContain('/modules/n8n/workflows'); + expect($content)->toContain('/modules/n8n/workflows/{id}'); + expect($content)->toContain('/modules/n8n/workflows/{id}/publish'); + expect($content)->toContain('/modules/n8n/workflows/{id}/deactivate'); + expect($content)->toContain("requirePermission('modules_n8n_workflows_view')"); + expect($content)->toContain("requirePermission('modules_n8n_workflows_manage')"); +}); + +it('registers execution and webhook trigger endpoints for the n8n module', function (): void { + $routeFile = app_path('routes/moduleN8nRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/modules/n8n/webhooks/trigger'); + expect($content)->toContain('/modules/n8n/executions'); + expect($content)->toContain('/modules/n8n/executions/{id}/retry'); + expect($content)->toContain('/modules/n8n/executions/{id}/stop'); + expect($content)->toContain("requirePermission('modules_n8n_workflows_run')"); + expect($content)->toContain("requirePermission('modules_n8n_executions_view')"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php new file mode 100644 index 00000000..d03199c0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php @@ -0,0 +1,69 @@ + 10], + ['id' => 20], + ]; + + $rules = [ + [ + 'condition_id' => 10, + 'type' => selfserve_condition_rule_type::IS_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 1, + ], + [ + 'condition_id' => 10, + 'type' => selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 2, + ], + [ + 'condition_id' => 20, + 'type' => selfserve_condition_rule_type::IS_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 5, + ], + [ + 'condition_id' => 20, + 'type' => selfserve_condition_rule_type::IS_TRUE_OR_ANY_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 3, + ], + [ + 'condition_id' => 20, + 'type' => selfserve_condition_rule_type::IS_TRUE_OR_ANY_TRUE->value, + 'object_type' => selfserve_condition_rule_object_type::QUESTION->value, + 'object_id' => 4, + ], + ]; + + $answers = [ + 1 => true, + 3 => false, + 4 => true, + 5 => true, + ]; + + expect($evaluator->evaluate($conditions, $rules, $answers))->toBe([ + 10 => true, + 20 => true, + ]); +}); + +it('prefers condition results over question answers when evaluating task gates', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + expect($evaluator->taskGateSatisfied(14, [14 => false], [14 => true]))->toBeFalse(); + expect($evaluator->taskGateSatisfied(15, [], [15 => true]))->toBeTrue(); + expect($evaluator->taskGateSatisfied(null, [], []))->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php new file mode 100644 index 00000000..4d19d4bd --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -0,0 +1,43 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('documents self-serve machine type, eligibility, summary, and webhook endpoints', function (): void { + $content = selfserve_openapi_content_or_skip(); + + expect($content)->toContain('/department/selfserve/machine-types:'); + expect($content)->toContain('/department/selfserve/vehicle/allowed:'); + expect($content)->toContain('/department/selfserve/washes/summary:'); + expect($content)->toContain('/relay/button/press/post:'); +}); + +it('defines reusable self-serve wash and machine type schemas', function (): void { + $content = selfserve_openapi_content_or_skip(); + + expect($content)->toContain('SelfserveMachineType:'); + expect($content)->toContain('SelfserveVehicleAllowedResponse:'); + expect($content)->toContain('SelfserveWashSummary:'); + expect($content)->toContain('MachineButtonPressWebhookResponse:'); + expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:'); + expect($content)->toContain('machine_type_id:'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php new file mode 100644 index 00000000..ed6e5189 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -0,0 +1,33 @@ +not->toBeFalse(); + expect($machineTypesRoute)->toContain('/department/selfserve/machine-types'); + + expect($vehicleConditionsRoute)->not->toBeFalse(); + expect($vehicleConditionsRoute)->toContain('/department/selfserve/vehicle/allowed'); + expect($vehicleConditionsRoute)->toContain('/department/selfserve/washes/summary'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession'); + + expect($webhookRoute)->not->toBeFalse(); + expect($webhookRoute)->toContain('/relay/button/press/post'); + expect($webhookRoute)->toContain('recordMachineStartWebhook'); +}); + +it('keeps machine type support wired into lanes, tasks, and conditions routes', function (): void { + $lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php')); + $tasksRoute = file_get_contents(app_path('routes/departmentSelfserveTasksRoute.php')); + $conditionsRoute = file_get_contents(app_path('routes/departmentSelfserveConditionsRoute.php')); + $questionsRoute = file_get_contents(app_path('routes/departmentSelfserveQuestionsRoute.php')); + + expect($lanesRoute)->toContain('machine_type_id'); + expect($tasksRoute)->toContain('machine_type_id'); + expect($conditionsRoute)->toContain('machine_type_id'); + expect($questionsRoute)->toContain('department') // shared questions still use the legacy columns with default 0 scope + ->toContain('lane') + ->toContain('product'); +});