Add reusable routes for department self-serve machine types, n8n workflows, and executions, with corresponding unit tests and service integration.
This commit is contained in:
@@ -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 }}"
|
||||
+137
-67
@@ -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
|
||||
mysql_data:
|
||||
redis_data:
|
||||
n8n_data:
|
||||
+529
-20
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/modules/n8n/n8n_c.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\n8n_i;
|
||||
use n8n\n8n_c;
|
||||
use stdClass;
|
||||
|
||||
class n8n implements n8n_i
|
||||
{
|
||||
private const WORKFLOW_READ_ONLY_FIELDS = [
|
||||
'id',
|
||||
'active',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'tags',
|
||||
'shared',
|
||||
'activeVersion',
|
||||
];
|
||||
|
||||
public n8n_c $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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 . ').';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive self-serve tables and columns exist.
|
||||
*
|
||||
* This project has no centralized migration runner, so the bootstrap must be
|
||||
* idempotent and safe to call from runtime flows.
|
||||
*/
|
||||
class selfserve_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS selfserve_machine_types (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
UNIQUE KEY uniq_selfserve_machine_types_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS selfserve_wash_sessions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lane_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
machine_type_id INT NULL,
|
||||
customer_number INT NULL,
|
||||
vehicle_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
reg VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(64) NOT NULL DEFAULT 'PENDING_QUESTIONS',
|
||||
allowed TINYINT(1) NOT NULL DEFAULT 0,
|
||||
machine_relay_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
machine_relay_enabled_at DATETIME NULL,
|
||||
machine_start_triggered TINYINT(1) NOT NULL DEFAULT 0,
|
||||
machine_start_triggered_at DATETIME NULL,
|
||||
order_id INT NULL,
|
||||
completed_at DATETIME NULL,
|
||||
metadata_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
|
||||
INDEX idx_selfserve_wash_sessions_status (status),
|
||||
INDEX idx_selfserve_wash_sessions_customer (customer_number),
|
||||
INDEX idx_selfserve_wash_sessions_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id INT NOT NULL,
|
||||
question_id INT NOT NULL,
|
||||
question_text VARCHAR(255) NOT NULL,
|
||||
answer_value TINYINT(1) NOT NULL,
|
||||
answered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
UNIQUE KEY uniq_selfserve_wash_session_answer (session_id, question_id),
|
||||
INDEX idx_selfserve_wash_session_answers_session (session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_tasks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id INT NOT NULL,
|
||||
task_id INT NULL,
|
||||
task_text VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
services JSON NULL,
|
||||
buttons JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_selfserve_wash_session_tasks_session (session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id INT NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
payload_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_selfserve_wash_session_events_session (session_id),
|
||||
INDEX idx_selfserve_wash_session_events_type (event_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace interfaces;
|
||||
|
||||
interface n8n_i extends universal_module_i
|
||||
{
|
||||
public function listWorkflows(array $filters = []): object;
|
||||
|
||||
public function getWorkflow(string $id, bool $excludePinnedData = false): object;
|
||||
|
||||
public function createWorkflow(object $workflow): object;
|
||||
|
||||
public function updateWorkflow(string $id, object $changes): object;
|
||||
|
||||
public function publishWorkflow(string $id, ?object $options = null): object;
|
||||
|
||||
public function deactivateWorkflow(string $id): object;
|
||||
|
||||
public function runWebhook(string $webhookTarget, mixed $payload = null, string $method = 'POST', array $query = []): object;
|
||||
|
||||
public function listExecutions(array $filters = []): object;
|
||||
|
||||
public function getExecution(int $id, bool $includeData = false): object;
|
||||
|
||||
public function retryExecution(int $id, bool $loadWorkflow = false): object;
|
||||
|
||||
public function stopExecution(int $id): object;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace n8n\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class n8n_api_key_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'n8n',
|
||||
'api_key',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'The n8n API key sent as the X-N8N-API-KEY header.',
|
||||
'n8n_api_xxxxx',
|
||||
true,
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace n8n\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class n8n_api_url_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'n8n',
|
||||
'api_url',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'The base URL for the n8n public API, including the /api/v1 path.',
|
||||
'http://n8n:5678/api/v1',
|
||||
false,
|
||||
'http://n8n:5678/api/v1'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace n8n\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class n8n_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'n8n',
|
||||
'enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether the n8n module is enabled or not.',
|
||||
'1',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace n8n\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class n8n_webhook_base_url_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'n8n',
|
||||
'webhook_base_url',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'The base URL used when triggering n8n webhooks by relative path.',
|
||||
'http://n8n:5678',
|
||||
false,
|
||||
'http://n8n:5678'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace n8n;
|
||||
|
||||
require_once WD . '/modules/n8n/config/n8n_enabled_c.php';
|
||||
require_once WD . '/modules/n8n/config/n8n_api_url_c.php';
|
||||
require_once WD . '/modules/n8n/config/n8n_api_key_c.php';
|
||||
require_once WD . '/modules/n8n/config/n8n_webhook_base_url_c.php';
|
||||
|
||||
use n8n\config\n8n_api_key_c;
|
||||
use n8n\config\n8n_api_url_c;
|
||||
use n8n\config\n8n_enabled_c;
|
||||
use n8n\config\n8n_webhook_base_url_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class n8n_c
|
||||
{
|
||||
use module_config_t;
|
||||
|
||||
public n8n_enabled_c $enabled;
|
||||
public n8n_api_url_c $api_url;
|
||||
public n8n_api_key_c $api_key;
|
||||
public n8n_webhook_base_url_c $webhook_base_url;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\classes;
|
||||
|
||||
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_condition_rule_object_type.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_condition_rule_type.php';
|
||||
|
||||
use modules\selfserve\helpers\selfserve_condition_rule_object_type;
|
||||
use modules\selfserve\helpers\selfserve_condition_rule_type;
|
||||
use modules\selfserve\interfaces\selfserve_condition_evaluator_i;
|
||||
|
||||
class selfserve_condition_evaluator implements selfserve_condition_evaluator_i
|
||||
{
|
||||
public function evaluate(array $conditions, array $rules, array $answers): array
|
||||
{
|
||||
$rulesByConditionId = [];
|
||||
foreach ($rules as $rule) {
|
||||
$conditionId = (int)($rule['condition_id'] ?? 0);
|
||||
if ($conditionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rulesByConditionId[$conditionId][] = $rule;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$resolving = [];
|
||||
$resolver = function (int $conditionId) use (&$resolver, &$results, &$resolving, $rulesByConditionId, $answers): bool {
|
||||
if (array_key_exists($conditionId, $results)) {
|
||||
return $results[$conditionId];
|
||||
}
|
||||
if (isset($resolving[$conditionId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolving[$conditionId] = true;
|
||||
$conditionRules = $rulesByConditionId[$conditionId] ?? [];
|
||||
if ($conditionRules === []) {
|
||||
unset($resolving[$conditionId]);
|
||||
$results[$conditionId] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
$orRules = [];
|
||||
$andRules = [];
|
||||
foreach ($conditionRules as $rule) {
|
||||
$ruleType = selfserve_condition_rule_type::tryFrom((string)($rule['type'] ?? ''));
|
||||
if ($ruleType === selfserve_condition_rule_type::IS_TRUE_OR_ANY_TRUE) {
|
||||
$orRules[] = $rule;
|
||||
continue;
|
||||
}
|
||||
$andRules[] = $rule;
|
||||
}
|
||||
|
||||
$andSatisfied = true;
|
||||
foreach ($andRules as $rule) {
|
||||
if (!$this->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<string,mixed> $rule
|
||||
* @param array<int,bool|null> $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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\classes;
|
||||
|
||||
require_once WD . '/classes/selfserve.php';
|
||||
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_wash_event_type.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php';
|
||||
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
|
||||
require_once WD . '/modules/selfserve/interfaces/selfserve_wash_flow_i.php';
|
||||
require_once WD . '/objects/customer_vehicles_o.php';
|
||||
require_once WD . '/objects/department_lanes_o.php';
|
||||
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
|
||||
require_once WD . '/objects/department_selfserve_conditions_o.php';
|
||||
require_once WD . '/objects/department_selfserve_questions_o.php';
|
||||
require_once WD . '/objects/department_selfserve_tasks_o.php';
|
||||
require_once WD . '/objects/department_selfserve_vehicle_conditions_o.php';
|
||||
require_once WD . '/objects/selfserve_machine_types_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_session_answers_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_session_events_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
use modules\selfserve\helpers\selfserve_lane_services;
|
||||
use modules\selfserve\helpers\selfserve_lane_state;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use modules\selfserve\helpers\selfserve_wash_event_type;
|
||||
use modules\selfserve\helpers\selfserve_wash_session_status;
|
||||
use modules\selfserve\interfaces\selfserve_condition_evaluator_i;
|
||||
use modules\selfserve\interfaces\selfserve_wash_flow_i;
|
||||
use objects\customer_vehicles_o;
|
||||
use objects\department_lanes_o;
|
||||
use objects\department_selfserve_condition_rules_o;
|
||||
use objects\department_selfserve_conditions_o;
|
||||
use objects\department_selfserve_questions_o;
|
||||
use objects\department_selfserve_tasks_o;
|
||||
use objects\department_selfserve_vehicle_conditions_o;
|
||||
use objects\selfserve_machine_types_o;
|
||||
use objects\selfserve_wash_session_answers_o;
|
||||
use objects\selfserve_wash_session_events_o;
|
||||
use objects\selfserve_wash_session_tasks_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
|
||||
class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
{
|
||||
public function __construct(
|
||||
protected ?selfserve_condition_evaluator_i $conditionEvaluator = null,
|
||||
) {
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\helpers;
|
||||
|
||||
enum selfserve_condition_rule_object_type: string
|
||||
{
|
||||
case QUESTION = 'question';
|
||||
case CONDITION = 'condition';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\helpers;
|
||||
|
||||
enum selfserve_condition_rule_type: string
|
||||
{
|
||||
case IS_TRUE = 'IS_TRUE';
|
||||
case IS_FALSE = 'IS_FALSE';
|
||||
case IS_SET = 'IS_SET';
|
||||
case IS_TRUE_OR_NOT_SET = 'IS_TRUE_OR_NOT_SET';
|
||||
case IS_FALSE_OR_NOT_SET = 'IS_FALSE_OR_NOT_SET';
|
||||
case IS_TRUE_OR_ANY_TRUE = 'IS_TRUE_OR_ANY_TRUE';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\helpers;
|
||||
|
||||
enum selfserve_wash_event_type: string
|
||||
{
|
||||
case SESSION_SYNCED = 'SESSION_SYNCED';
|
||||
case MACHINE_RELAY_ENABLED = 'MACHINE_RELAY_ENABLED';
|
||||
case MACHINE_START_TRIGGERED = 'MACHINE_START_TRIGGERED';
|
||||
case SESSION_COMPLETED = 'SESSION_COMPLETED';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\helpers;
|
||||
|
||||
enum selfserve_wash_session_status: string
|
||||
{
|
||||
case PENDING_QUESTIONS = 'PENDING_QUESTIONS';
|
||||
case READY_FOR_MACHINE_START = 'READY_FOR_MACHINE_START';
|
||||
case MACHINE_NOT_ALLOWED = 'MACHINE_NOT_ALLOWED';
|
||||
case MACHINE_RELAY_ENABLED = 'MACHINE_RELAY_ENABLED';
|
||||
case MACHINE_STARTED = 'MACHINE_STARTED';
|
||||
case COMPLETED = 'COMPLETED';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\interfaces;
|
||||
|
||||
interface selfserve_condition_evaluator_i
|
||||
{
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
* @param array<int,bool|null> $answers
|
||||
* @return array<int,bool>
|
||||
*/
|
||||
public function evaluate(array $conditions, array $rules, array $answers): array;
|
||||
|
||||
/**
|
||||
* @param int|null $gateId
|
||||
* @param array<int,bool> $conditionResults
|
||||
* @param array<int,bool|null> $answers
|
||||
* @return bool
|
||||
*/
|
||||
public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\interfaces;
|
||||
|
||||
interface selfserve_wash_flow_i
|
||||
{
|
||||
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null): array;
|
||||
|
||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true): array;
|
||||
|
||||
public function recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = []): array;
|
||||
|
||||
public function getSessionSummary(int $sessionId): array;
|
||||
|
||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
|
||||
use Exception;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
@@ -133,6 +134,17 @@ trait selfserve_lane_command_t
|
||||
}
|
||||
// Log the lane stop event
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_machine_types_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $name;
|
||||
public object_property $description;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_session_answers_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $session_id;
|
||||
public object_property $question_id;
|
||||
public object_property $question_text;
|
||||
public object_property $answer_value;
|
||||
public object_property $answered_at;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use modules\selfserve\helpers\selfserve_wash_event_type;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_session_events_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $session_id;
|
||||
public object_property $event_type;
|
||||
public object_property $payload_json;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_session_tasks_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $session_id;
|
||||
public object_property $task_id;
|
||||
public object_property $task_text;
|
||||
public object_property $description;
|
||||
public object_property $services;
|
||||
public object_property $buttons;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use modules\selfserve\helpers\selfserve_wash_session_status;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_sessions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $lane_id;
|
||||
public object_property $department_id;
|
||||
public object_property $machine_type_id;
|
||||
public object_property $customer_number;
|
||||
public object_property $vehicle_id;
|
||||
public object_property $vehicle_type_id;
|
||||
public object_property $reg;
|
||||
public object_property $status;
|
||||
public object_property $allowed;
|
||||
public object_property $machine_relay_enabled;
|
||||
public object_property $machine_relay_enabled_at;
|
||||
public object_property $machine_start_triggered;
|
||||
public object_property $machine_start_triggered_at;
|
||||
public object_property $order_id;
|
||||
public object_property $completed_at;
|
||||
public object_property $metadata_json;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* Route for reusable self-serve machine types
|
||||
*/
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use objects\logs_o;
|
||||
use objects\selfserve_machine_types_o;
|
||||
use traits\route_t;
|
||||
|
||||
class departmentSelfserveMachineTypesRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\n8n;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use objects\logs_o;
|
||||
use stdClass;
|
||||
use traits\route_t;
|
||||
|
||||
class moduleN8nRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
global /** @var response $response */
|
||||
/** @var router $router */
|
||||
$router, $response;
|
||||
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/economic_v2_versioning_service.php');
|
||||
app_require('classes/economic_v2_distribution_service.php');
|
||||
|
||||
use classes\economic_v2_distribution_service;
|
||||
use classes\economic_v2_versioning_service;
|
||||
|
||||
if (!class_exists('FakeEconomicV2DistributionVersioningService')) {
|
||||
class FakeEconomicV2DistributionVersioningService extends economic_v2_versioning_service
|
||||
{
|
||||
public ?array $fixedVersion = null;
|
||||
public array $subscriptionVersions = [];
|
||||
public int $backfillCalls = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
|
||||
{
|
||||
return $this->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).');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
app_require('routes/moduleN8nRoute.php');
|
||||
|
||||
use routes\moduleN8nRoute;
|
||||
|
||||
function n8n_route_invoke_private(moduleN8nRoute $route, string $method, array $args = []): mixed
|
||||
{
|
||||
$reflection = new ReflectionClass($route);
|
||||
$target = $reflection->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',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
it('registers workflow lifecycle 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/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')");
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
app_require('modules/selfserve/classes/selfserve_condition_evaluator.php');
|
||||
|
||||
use modules\selfserve\classes\selfserve_condition_evaluator;
|
||||
use modules\selfserve\helpers\selfserve_condition_rule_object_type;
|
||||
use modules\selfserve\helpers\selfserve_condition_rule_type;
|
||||
|
||||
it('evaluates self-serve conditions with combined AND and OR semantics', function (): void {
|
||||
$evaluator = new selfserve_condition_evaluator();
|
||||
|
||||
$conditions = [
|
||||
['id' => 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();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
function selfserve_openapi_content_or_skip(): string
|
||||
{
|
||||
$candidates = [
|
||||
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml',
|
||||
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml',
|
||||
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml',
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (!is_file($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($candidate);
|
||||
if ($content !== false) {
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
|
||||
test()->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:');
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
it('wires self-serve machine types, eligibility, summaries, and machine-start webhook routes', function (): void {
|
||||
$machineTypesRoute = file_get_contents(app_path('routes/departmentSelfserveMachineTypesRoute.php'));
|
||||
$vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php'));
|
||||
$webhookRoute = file_get_contents(app_path('routes/machineButtonPressRoute.php'));
|
||||
|
||||
expect($machineTypesRoute)->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');
|
||||
});
|
||||
Reference in New Issue
Block a user