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:
Jeppe Bundgaard
2026-03-16 11:36:48 +01:00
parent ca573783d2
commit d890d589ff
48 changed files with 4812 additions and 356 deletions
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+587
View File
@@ -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);
}
}
+28
View File
@@ -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'
);
}
}
+40
View File
@@ -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;
}
}
@@ -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');
});