Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc22976873 | ||
|
|
7a1c444df0 | ||
|
|
6a00f023b1 | ||
|
|
8aefbd8fb3 | ||
|
|
a7181a4ab2 | ||
|
|
fc6c76ad1b | ||
|
|
6a694f92cc | ||
|
|
b7a2dc04d7 | ||
|
|
870b88e707 | ||
|
|
e14cddc1fb | ||
|
|
31887fa8c9 | ||
|
|
eac83b18a0 | ||
|
|
3817a37021 | ||
|
|
940a3e5e9b | ||
|
|
3221223865 | ||
|
|
b51006d9d1 | ||
|
|
6b7592921d | ||
|
|
f26a427510 | ||
|
|
6de747252f | ||
|
|
ff225ff5e7 | ||
|
|
dcef993f12 | ||
|
|
23aca449f7 | ||
|
|
31b5ba136a | ||
|
|
f0b5479f30 | ||
|
|
b77efc538a | ||
|
|
10d1eb5bac | ||
|
|
084435e9b8 | ||
|
|
172a21c517 | ||
|
|
c24428e4c7 | ||
|
|
bf1d6a583e | ||
|
|
08ac16e665 | ||
|
|
79185a3c76 | ||
|
|
3a730e3507 | ||
|
|
ce43c4e064 | ||
|
|
579ddcf510 | ||
|
|
0b342a7780 | ||
|
|
57bcbaf72a | ||
|
|
d9fbba3130 | ||
|
|
e4465d9d91 | ||
|
|
734cd13c87 | ||
|
|
d0f94ac549 | ||
|
|
1d25cbe21c | ||
|
|
53d0636193 | ||
|
|
04bb26f1b0 | ||
|
|
df0d4783d0 | ||
|
|
39c06ceab6 | ||
|
|
7dd428d18e | ||
|
|
0103a40156 | ||
|
|
e208b1b2a4 | ||
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
ed2736e528 | ||
|
|
c7f5c73a9e | ||
|
|
c10af48954 | ||
|
|
7ac5c5585b | ||
|
|
8544ce0a18 | ||
|
|
614715822f | ||
|
|
1da02e2486 | ||
|
|
742b15116d | ||
|
|
e0ae74bdc2 | ||
|
|
08dc803b3e | ||
|
|
248a901f24 | ||
|
|
8bbdf9daf5 | ||
|
|
c089186046 | ||
|
|
2ae1fc3fcf | ||
|
|
d9eacf6f84 | ||
|
|
f262047476 | ||
|
|
b8390ac0d3 | ||
|
|
0d4a5470e5 | ||
|
|
845ca6e48e | ||
|
|
1cda2a81aa | ||
|
|
8e46ce1b04 | ||
|
|
9f797bf6b8 | ||
|
|
d345db927f | ||
|
|
11c2a1b72e | ||
|
|
eca7a81f9d | ||
|
|
62f2c80dda | ||
|
|
6f3d7e0f7d | ||
|
|
430c90cbca | ||
|
|
f02dfd8c9c | ||
|
|
a8fba73d99 | ||
|
|
669759461d | ||
|
|
db1b9a2c96 | ||
|
|
38814545c4 | ||
|
|
94c3654240 | ||
|
|
9fa249cc11 | ||
|
|
215c8d0fbb | ||
|
|
84dec4c0a2 | ||
|
|
d47ea1d659 | ||
|
|
3f41eebdf6 | ||
|
|
64e0b2444b | ||
|
|
18fede78f8 | ||
|
|
243d68ab59 | ||
|
|
62c1393f62 | ||
|
|
f0a8299133 | ||
|
|
e36f6da926 | ||
|
|
4a8c2a9fd9 | ||
|
|
248b2e4eca | ||
|
|
1d1ebd2176 | ||
|
|
4252f9a42b | ||
|
|
4fdeedab45 | ||
|
|
866a5be126 | ||
|
|
11d39af934 | ||
|
|
f706531534 | ||
|
|
a839eac4c1 | ||
|
|
581d28e9ce | ||
|
|
2ba39b8174 | ||
|
|
6af55a44c9 | ||
|
|
24badc39d7 | ||
|
|
f5e0baaab6 | ||
|
|
178c84ba60 | ||
|
|
713d40a876 | ||
|
|
9db1964038 | ||
|
|
1ca42055b0 | ||
|
|
c04bda7368 | ||
|
|
1f47843699 | ||
|
|
dca738db82 | ||
|
|
57f364ad0f | ||
|
|
a826153bb5 | ||
|
|
72bd22a707 | ||
|
|
cc73d80dbc | ||
|
|
f0a5b15442 | ||
|
|
eefe5630f4 | ||
|
|
b0ea771e6a | ||
|
|
eb21405a3d | ||
|
|
8ea10ef808 | ||
|
|
ac7da807bd | ||
|
|
3eafc597c6 | ||
|
|
f7a4126718 | ||
|
|
c6cf953ede | ||
|
|
d902202fe9 | ||
|
|
acc80920f9 | ||
|
|
53246af629 | ||
|
|
bd87e94472 | ||
|
|
7ccbb68ffa | ||
|
|
4a7fc7c534 | ||
|
|
f4343ae114 | ||
|
|
0da02dfeb5 | ||
|
|
5f13242cfa | ||
|
|
b492292642 | ||
|
|
ce8ba88b16 | ||
|
|
02b6df5e3b | ||
|
|
6cc4f2759d | ||
|
|
3ea92be722 | ||
|
|
42f8ae0c47 | ||
|
|
148b575767 | ||
|
|
605efacece | ||
|
|
1ccd7749d0 | ||
|
|
b3ba3c8de5 | ||
|
|
4a65b669bd | ||
|
|
aaec443140 | ||
|
|
3cdf1571c5 | ||
|
|
36b934e835 | ||
|
|
5027d0c919 | ||
|
|
f0baadd59f | ||
|
|
19cacebaa1 | ||
|
|
4d9d61455f | ||
|
|
fc87b3a8aa | ||
|
|
8e0936001d | ||
|
|
af8968a87e | ||
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a | ||
|
|
df7153a5ba | ||
|
|
d06c78119b | ||
|
|
bdb1a0074b | ||
|
|
c0de0e9d6b | ||
|
|
574b263a54 | ||
|
|
36ff5bb438 | ||
|
|
1beca924fc | ||
|
|
d605eca574 | ||
|
|
cea469c95a | ||
|
|
7e85c74e60 | ||
|
|
67d62eff70 | ||
|
|
8ebbd52a99 | ||
|
|
6d6cc501db | ||
|
|
ce999afbb3 | ||
|
|
cb34b030c8 | ||
|
|
e26034dfae | ||
|
|
0aad41fd0f | ||
|
|
5c67fe419f | ||
|
|
aca8be51dc | ||
|
|
fb1f0883e1 | ||
|
|
6446eb2e36 | ||
|
|
a1224ec2f4 | ||
|
|
07441c4ed1 | ||
|
|
cd100f1180 | ||
|
|
4fc66c72b8 | ||
|
|
a3ea5fee83 | ||
|
|
ef8d97c821 | ||
|
|
327a77edf4 | ||
|
|
d8abc8f87d | ||
|
|
325b35beb7 | ||
|
|
49364864d2 | ||
|
|
a19178a042 | ||
|
|
91d3332d4e | ||
|
|
bedbf21c29 | ||
|
|
75c19bcce4 | ||
|
|
458fe7399d | ||
|
|
2b6a8eedcc | ||
|
|
c0ed107f75 | ||
|
|
30dceff0b5 | ||
|
|
716929bd7b | ||
|
|
3d221f3379 | ||
|
|
6f1c160fbb | ||
|
|
9694695f00 | ||
|
|
1d43221b4d | ||
|
|
33b7c3e51a | ||
|
|
1e64bd63b8 | ||
|
|
bcbc2481c3 | ||
|
|
8288a1069c | ||
|
|
1b99523366 | ||
|
|
6d739cfebc | ||
|
|
20071166f8 | ||
|
|
c23168afc5 | ||
|
|
72704b7806 | ||
|
|
f7485f0767 |
@@ -11,6 +11,8 @@ services:
|
||||
|
||||
edge-broker:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||
ports:
|
||||
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
|
||||
labels:
|
||||
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
||||
@@ -80,3 +82,9 @@ services:
|
||||
|
||||
volumes:
|
||||
ci_php_app:
|
||||
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
|
||||
|
||||
@@ -9,8 +9,8 @@ on:
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
# Use GitHub-hosted runners for PR scans so untrusted code never runs on persistent internal infrastructure.
|
||||
runs-on: ubuntu-latest
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
+122
-42
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
php:
|
||||
name: PHP ${{ matrix.suite }} (required)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -19,6 +19,20 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@v4
|
||||
@@ -44,7 +58,7 @@ jobs:
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -54,8 +68,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-agent/package-lock.json
|
||||
|
||||
- name: Install native build tools
|
||||
run: |
|
||||
@@ -91,12 +103,26 @@ jobs:
|
||||
|
||||
edge-broker:
|
||||
name: Edge Broker (required)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -112,8 +138,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-broker/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: services/edge-broker
|
||||
@@ -125,26 +149,83 @@ jobs:
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
COMPOSE_PROFILES: dev
|
||||
TRAEFIK_WEB_PORT: "18080"
|
||||
TRAEFIK_WEBSECURE_PORT: "18443"
|
||||
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
||||
TRAEFIK_METRICS_PORT: "19100"
|
||||
EDGE_BROKER_CI_PORT: "14300"
|
||||
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Allocate CI ports
|
||||
run: |
|
||||
set -euo pipefail
|
||||
find_free_port() {
|
||||
start="$1"
|
||||
end="$2"
|
||||
port="$start"
|
||||
while [ "$port" -le "$end" ]; do
|
||||
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
|
||||
echo "$port"
|
||||
return 0
|
||||
fi
|
||||
port=$((port + 1))
|
||||
done
|
||||
|
||||
echo "No free port in range ${start}-${end}." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
base=$((20000 + (GITHUB_RUN_ID % 20000)))
|
||||
web_port="$(find_free_port "$base" "$((base + 2000))")"
|
||||
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
|
||||
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
|
||||
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
|
||||
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
|
||||
|
||||
{
|
||||
echo "TRAEFIK_WEB_PORT=${web_port}"
|
||||
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
|
||||
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
|
||||
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
|
||||
echo "EDGE_BROKER_CI_PORT=${broker_port}"
|
||||
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
|
||||
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
|
||||
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -152,7 +233,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Boot local stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: >
|
||||
@@ -161,10 +242,33 @@ jobs:
|
||||
--exclude='./.phpunit.cache'
|
||||
--exclude='./build/logs'
|
||||
-C services/nginx/app -cf - .
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
composer_install() {
|
||||
install_mode="$1"
|
||||
max_attempts="$2"
|
||||
attempt=1
|
||||
while :; do
|
||||
if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
composer_install --prefer-dist 3 || {
|
||||
echo "Composer dist install failed; retrying with --prefer-source." >&2
|
||||
composer_install --prefer-source 2
|
||||
}
|
||||
|
||||
- name: Verify edge gateway test files
|
||||
run: >
|
||||
@@ -223,34 +327,10 @@ jobs:
|
||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||
|
||||
- name: Run edge gateway E2E smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
docker rm -f "$runner" >/dev/null 2>&1 || true
|
||||
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
|
||||
docker create \
|
||||
--name "$runner" \
|
||||
--network "${compose_project}_default" \
|
||||
-e COMPOSE_FILE="$COMPOSE_FILE" \
|
||||
-e COMPOSE_PROJECT_NAME="$compose_project" \
|
||||
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
|
||||
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
|
||||
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
|
||||
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
|
||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace \
|
||||
node:22-alpine \
|
||||
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
|
||||
docker cp . "$runner:/workspace"
|
||||
docker start "$runner" >/dev/null
|
||||
docker logs -f "$runner"
|
||||
exit_code="$(docker wait "$runner")"
|
||||
exit "$exit_code"
|
||||
env:
|
||||
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
|
||||
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
|
||||
run: node scripts/edge-gateway-e2e.mjs
|
||||
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
@@ -258,7 +338,7 @@ jobs:
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
|
||||
@@ -279,7 +359,7 @@ jobs:
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}")"
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
|
||||
response_body="$(cat "$response_file")"
|
||||
rm -f "$response_file"
|
||||
|
||||
|
||||
+4
-1
@@ -13,4 +13,7 @@
|
||||
.env.old
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/.env_old_2
|
||||
/.openclaw/
|
||||
/services/nginx/app/build/phpstan/
|
||||
|
||||
@@ -40,6 +40,7 @@ COPY . /var/www/html
|
||||
|
||||
# Copy Nginx configuration file
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -47,6 +47,7 @@ RUN set -eux; \
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,8 @@ services:
|
||||
traefik:
|
||||
image: traefik:2.11
|
||||
container_name: traefik
|
||||
group_add:
|
||||
- "${DOCKER_SOCKET_GID:-65534}"
|
||||
ports:
|
||||
- "${TRAEFIK_WEB_PORT:-80}:80"
|
||||
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
||||
@@ -102,6 +104,7 @@ services:
|
||||
image: mysql:8.4
|
||||
container_name: mysql-debug
|
||||
profiles: [dev]
|
||||
command: ["mysqld", "--innodb-use-native-aio=0"]
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
|
||||
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
||||
|
||||
@@ -3357,6 +3357,9 @@
|
||||
},
|
||||
"email_notifications_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"superuser_new_customer_email_notifications_enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12955,6 +12958,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/slack/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "Get Slack config",
|
||||
"operationId": "getSlackConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slack configuration retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SlackConfigListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "Update Slack config",
|
||||
"operationId": "updateSlackConfig",
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slack configuration updated successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModuleConfigUpdateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/backups/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -15496,6 +15547,39 @@
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"SlackConfigEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Slack"
|
||||
]
|
||||
},
|
||||
"variable": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"customer_registration_webhook_url"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"example": "https://hooks.slack.com/services/..."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"module",
|
||||
"variable",
|
||||
"type",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"BackupsConfigEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16240,6 +16324,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SlackConfigListResponse": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SlackConfigEntry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"BackupsConfigListResponse": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+4023
-278
File diff suppressed because it is too large
Load Diff
+132545
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "Usage: $0 <service> [service ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
|
||||
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
|
||||
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
|
||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||
|
||||
compose_up() {
|
||||
attempt=1
|
||||
while :; do
|
||||
docker network prune -f >/dev/null 2>&1 || true
|
||||
|
||||
if docker compose $compose_files up -d "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
status="$?"
|
||||
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
(
|
||||
flock 9
|
||||
compose_up "$@"
|
||||
) 9>"$lock_file"
|
||||
else
|
||||
echo "flock is not available; running Docker compose startup without a host lock." >&2
|
||||
compose_up "$@"
|
||||
fi
|
||||
@@ -0,0 +1,453 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-command-drain-proof.mjs [options]
|
||||
|
||||
Verifies that a broker-connected PHP compose edge agent still drains API-queued
|
||||
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createWorkerServer(state) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/relay/switch") {
|
||||
state.relaySwitchSeen = true;
|
||||
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
|
||||
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
online: true,
|
||||
on: true,
|
||||
output: true,
|
||||
raw: { source: "fake-worker" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, workerPort) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: "10.123.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, childExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
relaySwitchSeen: state.relaySwitchSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !childExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
relaySwitchSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const workerServer = createWorkerServer(state);
|
||||
let apiServer = null;
|
||||
let child = null;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let childExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const workerPort = await listen(workerServer);
|
||||
apiServer = createApiServer(state, brokerPort, workerPort);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
child = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("exit", () => {
|
||||
childExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !childExited) {
|
||||
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, childExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.relaySwitchSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.stdout = stdout.slice(-3000);
|
||||
error.stderr = stderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (child) {
|
||||
await stopChild(child);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(workerServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.stdout) {
|
||||
process.stderr.write(`stdout: ${error.stdout}\n`);
|
||||
}
|
||||
if (error.stderr) {
|
||||
process.stderr.write(`stderr: ${error.stderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_WORKER_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const AGENT_TOKEN = "agent-token";
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
workerPath: DEFAULT_WORKER_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--worker-path":
|
||||
options.workerPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-to-shelly-proof.mjs [options]
|
||||
|
||||
Runs the PHP edge agent and real LAN worker against fake broker, API, and
|
||||
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
|
||||
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
|
||||
call, reads Switch.GetStatus, and posts the command result.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--worker-path <path> PHP LAN worker artifact to execute.
|
||||
Default: ${DEFAULT_WORKER_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === null ? null : JSON.stringify(body);
|
||||
const request = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: requestPath,
|
||||
method,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(payload === null ? {} : {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
}),
|
||||
...headers,
|
||||
},
|
||||
timeout: 1000,
|
||||
}, (response) => {
|
||||
let raw = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
let decoded = {};
|
||||
try {
|
||||
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
|
||||
} catch {
|
||||
decoded = { __invalid: raw };
|
||||
}
|
||||
resolve({ status: response.statusCode || 0, body: decoded });
|
||||
});
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.on("timeout", () => {
|
||||
request.destroy(new Error("request timed out"));
|
||||
});
|
||||
if (payload !== null) {
|
||||
request.write(payload);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function reservePort() {
|
||||
const server = net.createServer();
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
await closeServer(server);
|
||||
return port;
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createShellyServer(state) {
|
||||
return http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
state.requests.push({
|
||||
service: "shelly",
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
});
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
|
||||
state.shellySwitchSetSeen = true;
|
||||
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { was_on: false, output: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
|
||||
state.shellyStatusSeen = true;
|
||||
if (url.searchParams.get("id") !== "0") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/relay/")) {
|
||||
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, shellyAddress) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: shellyAddress,
|
||||
local_ip: shellyAddress,
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
relay_id: "relay-proof",
|
||||
deviceGeneration: 2,
|
||||
device_generation: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (
|
||||
body.ok !== true ||
|
||||
body.result?.on !== true ||
|
||||
body.result?.output !== true ||
|
||||
body.result?.raw?.source !== "fake-shelly-rpc"
|
||||
) {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: AGENT_TOKEN,
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnWorker({ workerPath, phpImage, workerPort }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-e",
|
||||
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
|
||||
"-v",
|
||||
`${workerPath}:/lan-worker.php:ro`,
|
||||
phpImage,
|
||||
"php",
|
||||
"-S",
|
||||
`127.0.0.1:${workerPort}`,
|
||||
"/lan-worker.php",
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function waitForWorker(workerPort, child, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestJson({ port: workerPort, path: "/health" });
|
||||
if (response.status === 200 && response.body?.service === "lan-worker") {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw lastError || new Error("LAN worker did not become healthy");
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, agentExited, workerExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
shellySwitchSetSeen: state.shellySwitchSetSeen,
|
||||
shellyStatusSeen: state.shellyStatusSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !agentExited,
|
||||
workerStayedRunningUntilProofComplete: !workerExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
if (!fs.existsSync(options.workerPath)) {
|
||||
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
shellySwitchSetSeen: false,
|
||||
shellyStatusSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const shellyServer = createShellyServer(state);
|
||||
let apiServer = null;
|
||||
let agent = null;
|
||||
let worker = null;
|
||||
let agentStdout = "";
|
||||
let agentStderr = "";
|
||||
let workerStdout = "";
|
||||
let workerStderr = "";
|
||||
let agentExited = false;
|
||||
let workerExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const shellyPort = await listen(shellyServer);
|
||||
const workerPort = await reservePort();
|
||||
const shellyAddress = `127.0.0.1:${shellyPort}`;
|
||||
apiServer = createApiServer(state, brokerPort, shellyAddress);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
worker = spawnWorker({ ...options, workerPort });
|
||||
worker.stdout.on("data", (chunk) => {
|
||||
workerStdout += chunk.toString();
|
||||
});
|
||||
worker.stderr.on("data", (chunk) => {
|
||||
workerStderr += chunk.toString();
|
||||
});
|
||||
worker.once("exit", () => {
|
||||
workerExited = true;
|
||||
});
|
||||
await waitForWorker(workerPort, worker, 5000);
|
||||
|
||||
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
agent.stdout.on("data", (chunk) => {
|
||||
agentStdout += chunk.toString();
|
||||
});
|
||||
agent.stderr.on("data", (chunk) => {
|
||||
agentStderr += chunk.toString();
|
||||
});
|
||||
agent.once("exit", () => {
|
||||
agentExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
|
||||
if (
|
||||
state.brokerHandshakeSeen &&
|
||||
state.commandPollSeen &&
|
||||
state.shellySwitchSetSeen &&
|
||||
state.shellyStatusSeen &&
|
||||
state.resultSeen
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, agentExited, workerExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.shellySwitchSetSeen ||
|
||||
!state.shellyStatusSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.agentStdout = agentStdout.slice(-3000);
|
||||
error.agentStderr = agentStderr.slice(-3000);
|
||||
error.workerStdout = workerStdout.slice(-3000);
|
||||
error.workerStderr = workerStderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
workerPath: options.workerPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (agent) {
|
||||
await stopChild(agent);
|
||||
}
|
||||
if (worker) {
|
||||
await stopChild(worker);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(shellyServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.agentStdout) {
|
||||
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
|
||||
}
|
||||
if (error.agentStderr) {
|
||||
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
|
||||
}
|
||||
if (error.workerStdout) {
|
||||
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
|
||||
}
|
||||
if (error.workerStderr) {
|
||||
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+125
-54
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
|
||||
return normalizeBaseUrl(url.toString());
|
||||
}
|
||||
|
||||
function isLocalHost(hostname) {
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
const websocketUrl = new URL(String(rawUrl));
|
||||
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
||||
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = ciBrokerPort;
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
||||
websocketUrl.hostname = "edge-broker";
|
||||
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
|
||||
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = apiUrl.port;
|
||||
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
|
||||
}
|
||||
|
||||
if (isLocalHost(websocketUrl.hostname)) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
@@ -555,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
|
||||
const diagnostics = {};
|
||||
|
||||
if (gatewayId !== null && gatewayId > 0 && authToken) {
|
||||
try {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
diagnostics.gateway = {
|
||||
status: detail?.data?.status ?? null,
|
||||
channelStatus: detail?.data?.channel_status ?? null,
|
||||
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
|
||||
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
|
||||
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
|
||||
allowFailure: true,
|
||||
});
|
||||
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
|
||||
} catch (error) {
|
||||
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const rootDir = await resolveRootDir(scriptPath);
|
||||
@@ -669,22 +727,35 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
try {
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await readGatewayDiagnostics({
|
||||
baseUrl,
|
||||
authToken,
|
||||
gatewayId,
|
||||
containerName,
|
||||
});
|
||||
throw new Error([
|
||||
error instanceof Error ? error.message : String(error),
|
||||
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
const WebSocketImpl = await loadWebSocketImplementation();
|
||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||
@@ -707,11 +778,14 @@ async function main() {
|
||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||
);
|
||||
|
||||
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
|
||||
assert.equal(
|
||||
Boolean(readyMessage?.connected),
|
||||
true,
|
||||
"Gateway stream became ready before the broker reported the gateway as connected."
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => (
|
||||
message?.type === "gateway.stream.ready" && message?.connected === true
|
||||
) || (
|
||||
message?.type === "presence.changed" && message?.status === "connected"
|
||||
),
|
||||
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
|
||||
);
|
||||
|
||||
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||
@@ -739,10 +813,24 @@ async function main() {
|
||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||
|
||||
try {
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
||||
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED"
|
||||
|| streamMessages.some((message) => (
|
||||
message?.type === "task.updated"
|
||||
&& Number(message?.operationId || 0) === operationId
|
||||
&& message?.operation?.status === "COMPLETED"
|
||||
));
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
} catch (error) {
|
||||
let operationSnapshot = null;
|
||||
@@ -765,21 +853,6 @@ async function main() {
|
||||
throw new Error(diagnostic);
|
||||
}
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED";
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||
@@ -855,22 +928,20 @@ async function main() {
|
||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||
);
|
||||
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
|
||||
assert.ok(
|
||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||
"Gateway logs page did not persist the shell transcript."
|
||||
);
|
||||
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
assert.ok(
|
||||
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||
"Gateway logs page did not include the shell close audit event."
|
||||
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
|
||||
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
|
||||
},
|
||||
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
|
||||
);
|
||||
|
||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||
|
||||
+43
-6
@@ -18,6 +18,7 @@ cd "$repo_root"
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
@@ -68,6 +69,33 @@ retry_command() {
|
||||
done
|
||||
}
|
||||
|
||||
composer_install() {
|
||||
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
|
||||
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
|
||||
|
||||
if retry_command "$dist_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
|
||||
retry_command "$source_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
||||
}
|
||||
|
||||
configure_ci_docker_subnet() {
|
||||
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
@@ -87,7 +115,8 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
|
||||
configure_ci_docker_subnet
|
||||
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
@@ -110,10 +139,18 @@ tar \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||
tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
services/php/Dockerfile \
|
||||
services/php/php-fpm-pool.conf \
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
||||
|
||||
composer_install
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
||||
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
|
||||
export const REQUIRED_MANIFEST_ARTIFACTS = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
];
|
||||
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
||||
"/edge-agent/install-token/status",
|
||||
"/edge-agent/artifacts/manifest.json",
|
||||
"report_install_status",
|
||||
'begin_install_phase "VERIFY_TOKEN"',
|
||||
'begin_install_phase "VERIFY_ARTIFACTS"',
|
||||
'begin_install_phase "WAIT_FOR_CLAIM"',
|
||||
'report_install_status "FAILED"',
|
||||
"verify_manifest_artifact",
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
];
|
||||
|
||||
export function normalizeBaseUrl(url) {
|
||||
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
|
||||
name: "Ping",
|
||||
url: `${normalizedBaseUrl}/ping`,
|
||||
},
|
||||
{
|
||||
name: "Artifact manifest",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
|
||||
artifactName: "manifest.json",
|
||||
},
|
||||
{
|
||||
name: "Agent PHP artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
||||
artifactName: "agent.php",
|
||||
},
|
||||
{
|
||||
name: "Service unit artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
||||
artifactName: "truckwash-edge-agent.service",
|
||||
},
|
||||
{
|
||||
name: "Installer script",
|
||||
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
|
||||
];
|
||||
}
|
||||
|
||||
export function validateArtifactManifestBody(body) {
|
||||
const manifest = JSON.parse(String(body || ""));
|
||||
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
|
||||
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
|
||||
}
|
||||
if (!Array.isArray(manifest.artifacts)) {
|
||||
throw new Error("Artifact manifest is missing artifacts.");
|
||||
}
|
||||
|
||||
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
|
||||
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
|
||||
if (missingArtifacts.length) {
|
||||
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
|
||||
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
|
||||
if (!artifact) {
|
||||
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
if (sha256 !== artifact.sha256) {
|
||||
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
|
||||
}
|
||||
if (buffer.length !== artifact.bytes) {
|
||||
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
|
||||
}
|
||||
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export function validateInstallerScriptBody(body) {
|
||||
const source = String(body || "");
|
||||
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
||||
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
|
||||
const checks = buildChecks(baseUrl, installToken);
|
||||
const results = [];
|
||||
let artifactManifest = null;
|
||||
|
||||
for (const check of checks) {
|
||||
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
||||
const response = await fetch(check.url);
|
||||
const body = await response.text();
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
const result = {
|
||||
...check,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
bodyPreview: previewBody(body),
|
||||
bodyPreview: previewBody(body.toString("utf8")),
|
||||
};
|
||||
results.push(result);
|
||||
|
||||
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (check.name === "Artifact manifest") {
|
||||
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
|
||||
result.version = artifactManifest.version;
|
||||
result.artifactCount = artifactManifest.artifacts.length;
|
||||
}
|
||||
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
|
||||
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
|
||||
}
|
||||
if (check.name === "Installer script") {
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body);
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_STAGING_BASE_URL,
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
||||
buildChecks,
|
||||
normalizeBaseUrl,
|
||||
parseArgs,
|
||||
validateArtifactBodyAgainstManifest,
|
||||
validateArtifactManifestBody,
|
||||
validateInstallerScriptBody,
|
||||
} from "./staging-edge-gateway-smoke.mjs";
|
||||
|
||||
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
|
||||
|
||||
assert.deepEqual(checks.map((check) => check.url), [
|
||||
"https://api.truckwash.io:4433/ping",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
||||
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
||||
]);
|
||||
});
|
||||
|
||||
test("validateArtifactManifestBody requires v3 install artifacts", () => {
|
||||
const artifacts = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
|
||||
|
||||
const manifest = validateArtifactManifestBody(JSON.stringify({
|
||||
version: EXPECTED_INSTALL_VERSION,
|
||||
artifacts,
|
||||
}));
|
||||
|
||||
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
|
||||
});
|
||||
|
||||
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
|
||||
const body = Buffer.from("hello");
|
||||
const manifest = {
|
||||
artifacts: [{
|
||||
name: "agent.php",
|
||||
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
bytes: body.length,
|
||||
}],
|
||||
};
|
||||
|
||||
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
|
||||
});
|
||||
|
||||
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
||||
const script = `
|
||||
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
||||
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
|
||||
report_install_status "FAILED"
|
||||
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
||||
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
|
||||
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
|
||||
verify_manifest_artifact
|
||||
${EXPECTED_INSTALL_VERSION}
|
||||
`;
|
||||
|
||||
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
|
||||
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
|
||||
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
|
||||
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
||||
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
||||
@@ -409,14 +409,20 @@ async function startContainer({
|
||||
});
|
||||
|
||||
if (copyConfig) {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||
await fs.chmod(configFilePath, 0o666).catch(() => {});
|
||||
try {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
configFilePath,
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
} finally {
|
||||
await fs.chmod(configFilePath, 0o600).catch(() => {});
|
||||
}
|
||||
|
||||
await runCommand("docker", ["start", containerName], {
|
||||
cwd: rootDir,
|
||||
|
||||
Vendored
+58
@@ -754,6 +754,60 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, mapper) {
|
||||
const results = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
|
||||
const commands = Array.isArray(command?.payload?.commands)
|
||||
? command.payload.commands
|
||||
: Array.isArray(command?.commands)
|
||||
? command.commands
|
||||
: [];
|
||||
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
|
||||
|
||||
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
|
||||
const target = String(entry.target || entry.relay || "");
|
||||
const relayId = String(entry.relayId || entry.relay_id || "");
|
||||
try {
|
||||
const payload = await handler(entry, fetchImpl);
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
|
||||
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
||||
if (!url) {
|
||||
return null;
|
||||
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
|
||||
return await getRelayStatus(command.payload || {}, fetchImpl);
|
||||
case "SET_RELAY_STATE":
|
||||
return await setRelayState(command.payload || {}, fetchImpl);
|
||||
case "BATCH_RELAY_STATUS":
|
||||
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
|
||||
case "BATCH_SET_RELAY_STATE":
|
||||
return await executeRelayBatch(command, setRelayState, fetchImpl);
|
||||
case "RUN_UPDATE":
|
||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||
case "UNINSTALL_AGENT":
|
||||
|
||||
@@ -127,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
||||
assert.equal(switched.on, false);
|
||||
});
|
||||
|
||||
test("batch relay commands return per-relay results without failing the whole batch", async () => {
|
||||
const fakeFetch = async (url) => {
|
||||
const value = String(url);
|
||||
if (value.includes("10.1.0.31")) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { output: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("relay offline");
|
||||
};
|
||||
|
||||
const result = await handleAgentCommand({
|
||||
commandType: "BATCH_SET_RELAY_STATE",
|
||||
payload: {
|
||||
batch_id: "batch-1",
|
||||
commands: [
|
||||
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
|
||||
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
|
||||
],
|
||||
},
|
||||
}, { fetchImpl: fakeFetch });
|
||||
|
||||
assert.equal(result.batch_id, "batch-1");
|
||||
assert.equal(result.results.length, 2);
|
||||
assert.equal(result.results[0].ok, true);
|
||||
assert.equal(result.results[0].target, "MACHINE");
|
||||
assert.equal(result.results[1].ok, false);
|
||||
assert.match(result.results[1].error, /relay offline/);
|
||||
});
|
||||
|
||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||
|
||||
@@ -296,6 +296,12 @@ export function createBrokerServer(options = {}) {
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||
const ingestMachineSignal =
|
||||
options.ingestMachineSignal ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
|
||||
|
||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||
@@ -853,6 +859,11 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MACHINE_SIGNAL") {
|
||||
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||
if (!sessionRecord) {
|
||||
|
||||
@@ -766,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker ingests self-serve machine signals from connected agents", async () => {
|
||||
const machineSignals = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
ingestMachineSignal: async (gatewayId, payload) => {
|
||||
machineSignals.push({ gatewayId, payload });
|
||||
return { recorded: true, lane_id: payload.lane_id };
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "MACHINE_SIGNAL",
|
||||
payload: {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
|
||||
assert.equal(machineSignals[0].gatewayId, "701");
|
||||
assert.deepEqual(machineSignals[0].payload, {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
});
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -133,8 +133,14 @@ class attachments implements attachments_i
|
||||
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
||||
{
|
||||
$options = $this->normalizeAttachmentOptions($options);
|
||||
$rawType = trim($type, '`');
|
||||
$objectTypes = array_values(array_unique([
|
||||
$rawType,
|
||||
'`' . $rawType . '`',
|
||||
]));
|
||||
|
||||
return (new object_attachments_o())->getFieldsWhereIn([
|
||||
'object_type' => $type,
|
||||
'object_type' => $objectTypes,
|
||||
'object_id' => $object_ids,
|
||||
'deleted_at' => null
|
||||
], $options);
|
||||
|
||||
@@ -121,6 +121,16 @@ class coolify_api_client
|
||||
]);
|
||||
}
|
||||
|
||||
public function listApplicationEnvs(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/envs');
|
||||
}
|
||||
|
||||
public function deleteApplicationEnv(string $uuid, string $envUuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid) . '/envs/' . rawurlencode($envUuid));
|
||||
}
|
||||
|
||||
private static function bulkEnvData(array $env): array
|
||||
{
|
||||
$data = [];
|
||||
@@ -159,6 +169,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function stopApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
||||
}
|
||||
|
||||
public function deleteService(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
|
||||
@@ -1371,6 +1371,129 @@ class coolify_manager
|
||||
];
|
||||
}
|
||||
|
||||
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
|
||||
$dryRun = $this->toBool($input['dry_run'] ?? null, false);
|
||||
$instanceId = (int)($input['instance_id'] ?? 0);
|
||||
if ($instanceId <= 0) {
|
||||
$instanceId = $this->defaultInstanceId();
|
||||
}
|
||||
$instance = $this->getInstance($instanceId);
|
||||
$repositories = $this->githubRunnerRepositories($input);
|
||||
$labels = $this->githubRunnerLabels($input['labels'] ?? null);
|
||||
$countPerRepo = $this->githubRunnerCount($input['count_per_repo'] ?? $input['runner_count_per_repo'] ?? null);
|
||||
$serviceName = $this->githubRunnerServiceName($input['service_name'] ?? null);
|
||||
$resourceUuid = $this->nullableString($input['service_uuid'] ?? null)
|
||||
?? $this->nullableString($this->coolifyConfigValue('github_runner_service_uuid', ''));
|
||||
$token = $this->githubRunnerToken($input);
|
||||
$template = $this->githubRunnerComposeTemplate($repositories, $labels, $countPerRepo);
|
||||
$hash = $this->composeHash($template);
|
||||
|
||||
$plan = [
|
||||
'type' => 'deploy_github_runners',
|
||||
'instance_id' => $instanceId,
|
||||
'service_uuid' => $resourceUuid,
|
||||
'service_name' => $serviceName,
|
||||
'repositories' => $repositories,
|
||||
'labels' => $labels,
|
||||
'count_per_repo' => $countPerRepo,
|
||||
'compose_hash' => $hash,
|
||||
'action' => $resourceUuid === null ? 'create' : 'update',
|
||||
'token_set' => $token !== '',
|
||||
'token_source' => trim((string)($input['github_token'] ?? $input['token'] ?? '')) !== '' ? 'request' : 'config',
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
$this->audit(null, $instanceId, null, 'github_runners_planned', $actorUserId, 'info', $plan);
|
||||
return [
|
||||
'ok' => true,
|
||||
'dry_run' => true,
|
||||
'mutated' => false,
|
||||
'planned' => [$plan],
|
||||
'applied' => [],
|
||||
'errors' => [],
|
||||
'service_uuid' => $resourceUuid,
|
||||
'service_name' => $serviceName,
|
||||
'compose_hash' => $hash,
|
||||
'repositories' => $repositories,
|
||||
'labels' => $labels,
|
||||
'count_per_repo' => $countPerRepo,
|
||||
];
|
||||
}
|
||||
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('GitHub runner token is required to deploy self-hosted runners.');
|
||||
}
|
||||
|
||||
$client = $this->clientForInstance($instance);
|
||||
$apiResult = [];
|
||||
$action = $resourceUuid === null ? 'created' : 'updated';
|
||||
if ($resourceUuid === null) {
|
||||
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
|
||||
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
|
||||
if ($resourceUuid === '') {
|
||||
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
$apiResult = $client->updateService($resourceUuid, $this->githubRunnerServicePayload($instance, $input, $serviceName, $template, true));
|
||||
} catch (Throwable $throwable) {
|
||||
if (!str_contains(strtolower($throwable->getMessage()), '404')
|
||||
&& !str_contains(strtolower($throwable->getMessage()), 'not found')) {
|
||||
throw $throwable;
|
||||
}
|
||||
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
|
||||
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
|
||||
if ($resourceUuid === '') {
|
||||
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
|
||||
}
|
||||
$action = 'created';
|
||||
}
|
||||
}
|
||||
|
||||
$client->updateServiceEnvsBulk($resourceUuid, ['GITHUB_RUNNER_TOKEN' => $token]);
|
||||
$start = $this->startOrRestartService($client, $resourceUuid, $action === 'updated');
|
||||
$deployment = $client->deployResource($resourceUuid, false);
|
||||
|
||||
$this->setModuleConfigValue('Coolify', 'github_runner_service_uuid', $resourceUuid, 'string');
|
||||
$this->setModuleConfigValue('Coolify', 'github_runner_frontend_repository', $repositories['frontend'], 'string');
|
||||
$this->setModuleConfigValue('Coolify', 'github_runner_backend_repository', $repositories['backend'], 'string');
|
||||
$this->setModuleConfigValue('Coolify', 'github_runner_labels', implode(',', $labels), 'string');
|
||||
$this->setModuleConfigValue('Coolify', 'github_runner_count_per_repo', (string)$countPerRepo, 'int');
|
||||
if ($this->toBool($input['persist_token'] ?? null, false)) {
|
||||
$this->setModuleConfigValue('Coolify', 'github_runner_token', replication_secret_box::encrypt($token), 'string');
|
||||
}
|
||||
|
||||
$applied = array_replace($plan, [
|
||||
'action' => $action,
|
||||
'service_uuid' => $resourceUuid,
|
||||
'coolify' => self::redactCoolifyResponse($apiResult),
|
||||
'start' => self::redactCoolifyResponse(is_array($start) ? $start : []),
|
||||
'deployment' => self::redactCoolifyResponse($deployment),
|
||||
]);
|
||||
|
||||
$this->audit(null, $instanceId, null, 'github_runners_deployed', $actorUserId, 'info', $applied);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'dry_run' => false,
|
||||
'mutated' => true,
|
||||
'planned' => [$plan],
|
||||
'applied' => [$applied],
|
||||
'errors' => [],
|
||||
'action' => $action,
|
||||
'service_uuid' => $resourceUuid,
|
||||
'service_name' => $serviceName,
|
||||
'compose_hash' => $hash,
|
||||
'repositories' => $repositories,
|
||||
'labels' => $labels,
|
||||
'count_per_repo' => $countPerRepo,
|
||||
'deployment' => self::redactCoolifyResponse($deployment),
|
||||
];
|
||||
}
|
||||
|
||||
private function gatewayApiCodeVersionLabel(array $target): string
|
||||
{
|
||||
$channelSlug = trim((string)($target['channel_slug'] ?? 'gateway'));
|
||||
@@ -3266,6 +3389,141 @@ class coolify_manager
|
||||
];
|
||||
}
|
||||
|
||||
private function githubRunnerRepositories(array $input): array
|
||||
{
|
||||
return [
|
||||
'frontend' => $this->normalizeGithubRepository(
|
||||
$input['frontend_repository'] ?? $input['frontend_repo'] ?? $this->coolifyConfigValue('github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue'),
|
||||
'frontend'
|
||||
),
|
||||
'backend' => $this->normalizeGithubRepository(
|
||||
$input['backend_repository'] ?? $input['backend_repo'] ?? $this->coolifyConfigValue('github_runner_backend_repository', 'copenhagentruckwash/api'),
|
||||
'backend'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeGithubRepository(mixed $value, string $label): string
|
||||
{
|
||||
$repository = trim((string)$value);
|
||||
$repository = preg_replace('#^https://github\.com/#i', '', $repository) ?? $repository;
|
||||
$repository = preg_replace('#^git@github\.com:#i', '', $repository) ?? $repository;
|
||||
$repository = preg_replace('#\.git$#i', '', $repository) ?? $repository;
|
||||
$repository = trim($repository, " \t\n\r\0\x0B/");
|
||||
if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repository)) {
|
||||
throw new RuntimeException('GitHub ' . $label . ' repository must be in owner/repo format.');
|
||||
}
|
||||
return $repository;
|
||||
}
|
||||
|
||||
private function githubRunnerLabels(mixed $value): array
|
||||
{
|
||||
$raw = trim((string)($value ?? ''));
|
||||
if ($raw === '') {
|
||||
$raw = $this->coolifyConfigValue('github_runner_labels', 'self-hosted,Linux,X64,default');
|
||||
}
|
||||
|
||||
$labels = array_values(array_unique(array_filter(array_map(
|
||||
static fn(string $label): string => trim($label),
|
||||
preg_split('/[,\s]+/', $raw) ?: []
|
||||
))));
|
||||
|
||||
return $labels !== [] ? $labels : ['self-hosted', 'Linux', 'X64', 'default'];
|
||||
}
|
||||
|
||||
private function githubRunnerCount(mixed $value): int
|
||||
{
|
||||
$count = (int)($value ?? 0);
|
||||
if ($count <= 0) {
|
||||
$count = (int)$this->coolifyConfigValue('github_runner_count_per_repo', '1');
|
||||
}
|
||||
return max(1, min(10, $count));
|
||||
}
|
||||
|
||||
private function githubRunnerServiceName(mixed $value): string
|
||||
{
|
||||
$name = strtolower(trim((string)($value ?? 'truckwash-github-runners')));
|
||||
$name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?: '';
|
||||
$name = trim($name, '-') ?: 'truckwash-github-runners';
|
||||
return substr($name, 0, 120);
|
||||
}
|
||||
|
||||
private function githubRunnerToken(array $input): string
|
||||
{
|
||||
$token = trim((string)($input['github_token'] ?? $input['token'] ?? ''));
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$envToken = trim((string)(getenv('GITHUB_RUNNER_TOKEN') ?: getenv('GITHUB_TOKEN') ?: ''));
|
||||
if ($envToken !== '') {
|
||||
return $envToken;
|
||||
}
|
||||
|
||||
return replication_secret_box::decrypt($this->coolifyConfigValue('github_runner_token', ''));
|
||||
}
|
||||
|
||||
private function githubRunnerComposeTemplate(array $repositories, array $labels, int $countPerRepo): array
|
||||
{
|
||||
$lines = ['services:'];
|
||||
foreach ($repositories as $key => $repository) {
|
||||
for ($index = 1; $index <= $countPerRepo; $index++) {
|
||||
$service = 'github-runner-' . $key . '-' . $index;
|
||||
$runnerName = 'truckwash-' . $key . '-' . $index;
|
||||
$runnerLabels = array_values(array_unique(array_merge($labels, [$key])));
|
||||
$lines = array_merge($lines, [
|
||||
' ' . $service . ':',
|
||||
' image: myoung34/github-runner:latest',
|
||||
' restart: unless-stopped',
|
||||
' environment:',
|
||||
' REPO_URL: ' . self::yamlScalar('https://github.com/' . $repository),
|
||||
' RUNNER_NAME: ' . self::yamlScalar($runnerName),
|
||||
' RUNNER_SCOPE: repo',
|
||||
' RUNNER_WORKDIR: /tmp/runner/work',
|
||||
' LABELS: ' . self::yamlScalar(implode(',', $runnerLabels)),
|
||||
' EPHEMERAL: "false"',
|
||||
' RUN_AS_ROOT: "true"',
|
||||
' ACCESS_TOKEN: ${GITHUB_RUNNER_TOKEN}',
|
||||
' volumes:',
|
||||
' - /var/run/docker.sock:/var/run/docker.sock',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'compose' => implode("\n", $lines) . "\n",
|
||||
'env' => 'GITHUB_RUNNER_TOKEN=${GITHUB_RUNNER_TOKEN}',
|
||||
];
|
||||
}
|
||||
|
||||
private function githubRunnerServicePayload(array $instance, array $input, string $serviceName, array $template, bool $update): array
|
||||
{
|
||||
$payload = [
|
||||
'name' => $serviceName,
|
||||
'description' => 'Truckwash GitHub self-hosted runners for frontend and backend workflows.',
|
||||
'instant_deploy' => false,
|
||||
'docker_compose_raw' => $this->encodedDockerCompose($template),
|
||||
'force_domain_override' => false,
|
||||
];
|
||||
|
||||
if (!$update) {
|
||||
$payload = array_replace($payload, [
|
||||
'project_uuid' => $this->targetMapping($input, $instance, 'project_uuid'),
|
||||
'environment_name' => $this->targetMapping($input, $instance, 'environment_name') ?: 'production',
|
||||
'environment_uuid' => $this->targetMapping($input, $instance, 'environment_uuid'),
|
||||
'server_uuid' => $this->targetMapping($input, $instance, 'server_uuid'),
|
||||
'destination_uuid' => $this->targetMapping($input, $instance, 'destination_uuid'),
|
||||
]);
|
||||
}
|
||||
|
||||
return array_filter($payload, static fn($value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
private static function yamlScalar(string $value): string
|
||||
{
|
||||
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
|
||||
}
|
||||
|
||||
private function publicLoadBalancerConfig(array $config): array
|
||||
{
|
||||
unset($config['token']);
|
||||
|
||||
@@ -154,6 +154,12 @@ class coolify_schema_bootstrap
|
||||
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'github_runner_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'github_runner_service_uuid', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'github_runner_backend_repository', 'copenhagentruckwash/api', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'github_runner_labels', 'self-hosted,Linux,X64,default', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'github_runner_count_per_repo', '1', 'int');
|
||||
|
||||
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
|
||||
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
|
||||
|
||||
@@ -6,6 +6,7 @@ class cors_policy
|
||||
{
|
||||
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const EXPOSED_HEADERS = 'Server-Timing';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
@@ -25,6 +26,9 @@ class cors_policy
|
||||
'https://localhost:4433',
|
||||
'https://twdev.jeppeb.dk',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
@@ -125,7 +129,9 @@ class cors_policy
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||
'Timing-Allow-Origin' => $origin,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_schedule
|
||||
{
|
||||
public static function normalize(array $schedule): array
|
||||
{
|
||||
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
|
||||
if ($type !== 'interval') {
|
||||
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
|
||||
}
|
||||
|
||||
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
|
||||
if ($seconds < 30 || $seconds > 2678400) {
|
||||
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'interval',
|
||||
'seconds' => $seconds,
|
||||
];
|
||||
}
|
||||
|
||||
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
|
||||
? strtotime($anchorDateTime)
|
||||
: false;
|
||||
$base = $anchor !== false ? (int)$anchor : $now;
|
||||
$next = $base + (int)$normalized['seconds'];
|
||||
|
||||
if ($next <= $now) {
|
||||
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
|
||||
$next += $missed * (int)$normalized['seconds'];
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $next);
|
||||
}
|
||||
|
||||
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
|
||||
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
|
||||
return self::nextRunAt($normalized, $lastRunAt, $now);
|
||||
}
|
||||
|
||||
if ($legacyLastRun !== null && $legacyLastRun > 0) {
|
||||
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class cron_scheduler
|
||||
{
|
||||
private cron_task_registry $registry;
|
||||
private string $lock_owner;
|
||||
|
||||
public function __construct(?cron_task_registry $registry = null)
|
||||
{
|
||||
$this->registry = $registry ?? new cron_task_registry();
|
||||
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
public function listTasks(): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$states = $this->stateRows();
|
||||
$estimates = $this->durationEstimates();
|
||||
$tasks = [];
|
||||
$now = time();
|
||||
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$state = $states[$definition->id] ?? [];
|
||||
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
|
||||
? $state['schedule']
|
||||
: $definition->schedule;
|
||||
$nextRunAt = $state['next_run_at'] ?? null;
|
||||
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
|
||||
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
|
||||
}
|
||||
|
||||
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
|
||||
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
|
||||
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
|
||||
$tasks[] = $task;
|
||||
}
|
||||
|
||||
return [
|
||||
'tasks' => $tasks,
|
||||
'summary' => [
|
||||
'total' => count($tasks),
|
||||
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
|
||||
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function listRuns(?string $task_id = null, int $limit = 50): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$limit = max(1, min(200, $limit));
|
||||
|
||||
$where = '';
|
||||
if ($task_id !== null && trim($task_id) !== '') {
|
||||
$where = "WHERE task_id = " . $this->sql($task_id);
|
||||
}
|
||||
|
||||
return $this->fetchAll(
|
||||
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
|
||||
);
|
||||
}
|
||||
|
||||
public function runDue(string $source = 'automatic'): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$ran = [];
|
||||
$now = time();
|
||||
$states = $this->stateRows();
|
||||
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$state = $states[$definition->id] ?? [];
|
||||
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nextRunAt = (string)($state['next_run_at'] ?? '');
|
||||
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
|
||||
} catch (Throwable $throwable) {
|
||||
$ran[] = [
|
||||
'task_id' => $definition->id,
|
||||
'module' => $definition->module,
|
||||
'source' => $source,
|
||||
'status' => 'skipped',
|
||||
'error_message' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ran' => $ran,
|
||||
'count' => count($ran),
|
||||
];
|
||||
}
|
||||
|
||||
public function runTask(
|
||||
string $task_id_or_legacy_name,
|
||||
string $source = 'manual',
|
||||
?int $actor_user_id = null,
|
||||
bool $force = false,
|
||||
?string $scheduled_for = null
|
||||
): array {
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id_or_legacy_name);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
throw new RuntimeException('Cron task is disabled.');
|
||||
}
|
||||
|
||||
if (!$this->claimLock($definition)) {
|
||||
throw new RuntimeException('Cron task is already running.');
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at);
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
$status = 'succeeded';
|
||||
$summary = [];
|
||||
$error_message = null;
|
||||
$output = '';
|
||||
|
||||
try {
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($definition->timeout_seconds + 30);
|
||||
}
|
||||
|
||||
$this->ensureLegacyFunctionsLoaded($definition);
|
||||
if (!is_callable($definition->handler)) {
|
||||
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
$result = call_user_func($definition->handler);
|
||||
$output = (string)ob_get_clean();
|
||||
$summary = is_array($result) ? $result : [];
|
||||
} catch (Throwable $throwable) {
|
||||
if (ob_get_level() > 0) {
|
||||
$output .= (string)ob_get_clean();
|
||||
}
|
||||
$status = 'failed';
|
||||
$error_message = $throwable->getMessage();
|
||||
}
|
||||
|
||||
$completed = microtime(true);
|
||||
$duration_ms = (int)round(($completed - $started) * 1000);
|
||||
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
|
||||
$status = 'timed_out';
|
||||
$error_message = 'Task exceeded its configured timeout window.';
|
||||
}
|
||||
|
||||
if ($output !== '') {
|
||||
$summary['output'] = substr($output, 0, 8000);
|
||||
}
|
||||
|
||||
$completed_at = date('Y-m-d H:i:s', (int)$completed);
|
||||
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
|
||||
$this->releaseLock($definition, $status, $error_message, $completed_at);
|
||||
|
||||
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
|
||||
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function updateTaskConfig(string $task_id, array $config): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if (array_key_exists('enabled', $config)) {
|
||||
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
|
||||
}
|
||||
|
||||
if (array_key_exists('schedule', $config)) {
|
||||
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
|
||||
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
|
||||
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
|
||||
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->listTasks();
|
||||
}
|
||||
|
||||
private function ensureReady(): void
|
||||
{
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
private function syncDefinitions(): void
|
||||
{
|
||||
$now = time();
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$row = $this->fetchOne(
|
||||
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
if ($row !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyLastRun = $this->legacyLastRun($definition);
|
||||
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ', '
|
||||
. ($definition->enabled ? '1' : '0') . ', NULL, '
|
||||
. $this->sql($nextRunAt)
|
||||
. ")"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function legacyLastRun(cron_task_definition $definition): ?int
|
||||
{
|
||||
if ($definition->legacy_name === null || !defined('redis')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$last_run = redis->get_last_crond_run($definition->legacy_name);
|
||||
return $last_run !== null ? (int)$last_run : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function claimLock(cron_task_definition $definition): bool
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET locked_until = " . $this->sql($locked_until) . ",
|
||||
lock_owner = " . $this->sql($this->lock_owner) . "
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
|
||||
);
|
||||
|
||||
return $this->affectedRows() === 1;
|
||||
}
|
||||
|
||||
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
|
||||
{
|
||||
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
|
||||
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
|
||||
if ($schedule === []) {
|
||||
$schedule = $definition->schedule;
|
||||
}
|
||||
|
||||
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
|
||||
if ($status !== 'succeeded') {
|
||||
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
|
||||
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_run_at = " . $this->sql($completed_at) . ",
|
||||
next_run_at = " . $this->sql($nextRunAt) . ",
|
||||
locked_until = NULL,
|
||||
lock_owner = NULL,
|
||||
current_run_id = NULL,
|
||||
last_status = " . $this->sql($status) . ",
|
||||
last_error = " . $this->nullableSql($error_message) . "
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND lock_owner = " . $this->sql($this->lock_owner)
|
||||
);
|
||||
|
||||
if ($definition->legacy_name !== null && defined('redis')) {
|
||||
try {
|
||||
redis->set_last_crond_run($definition->legacy_name, time());
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function createRun(
|
||||
cron_task_definition $definition,
|
||||
string $source,
|
||||
?int $actor_user_id,
|
||||
?string $scheduled_for,
|
||||
string $started_at
|
||||
): int {
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ', '
|
||||
. $this->sql($source) . ", 'running', "
|
||||
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||
. $this->nullableSql($scheduled_for) . ', '
|
||||
. $this->sql($started_at) . ', '
|
||||
. $this->sql($this->lock_owner)
|
||||
. ")"
|
||||
);
|
||||
|
||||
return $this->insertId();
|
||||
}
|
||||
|
||||
private function completeRun(
|
||||
int $run_id,
|
||||
string $status,
|
||||
string $completed_at,
|
||||
int $duration_ms,
|
||||
array $summary,
|
||||
?string $error_message
|
||||
): void {
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = " . $this->sql($status) . ",
|
||||
completed_at = " . $this->sql($completed_at) . ",
|
||||
duration_ms = " . (string)$duration_ms . ",
|
||||
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
|
||||
error_message = " . $this->nullableSql($error_message) . "
|
||||
WHERE id = " . (string)$run_id
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
|
||||
{
|
||||
if (function_exists($definition->handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('WD')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
|
||||
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
|
||||
}
|
||||
|
||||
require_once WD . '/cron/Cron.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function stateRows(): array
|
||||
{
|
||||
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
|
||||
$states = [];
|
||||
foreach ($rows as $row) {
|
||||
$row['enabled'] = (bool)$row['enabled'];
|
||||
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
|
||||
$states[(string)$row['task_id']] = $row;
|
||||
}
|
||||
return $states;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function durationEstimates(): array
|
||||
{
|
||||
$rows = $this->fetchAll(
|
||||
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
|
||||
FROM (
|
||||
SELECT task_id, duration_ms
|
||||
FROM cron_task_runs
|
||||
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
) recent_runs
|
||||
GROUP BY task_id"
|
||||
);
|
||||
|
||||
$estimates = [];
|
||||
foreach ($rows as $row) {
|
||||
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
|
||||
}
|
||||
return $estimates;
|
||||
}
|
||||
|
||||
private function decodeJson(mixed $json): array
|
||||
{
|
||||
if (!is_string($json) || trim($json) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function fetchOne(string $sql): ?array
|
||||
{
|
||||
$rows = $this->fetchAll($sql);
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$result = $this->query($sql);
|
||||
if ($result === false || $result === true) {
|
||||
return [];
|
||||
}
|
||||
return $result->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
private function query(string $sql): \mysqli_result|bool
|
||||
{
|
||||
global $db;
|
||||
return $db->query($sql);
|
||||
}
|
||||
|
||||
private function sql(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
|
||||
private function nullableSql(?string $value): string
|
||||
{
|
||||
return $value === null ? 'NULL' : $this->sql($value);
|
||||
}
|
||||
|
||||
private function affectedRows(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->conn()->affected_rows;
|
||||
}
|
||||
|
||||
private function insertId(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->insert_id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cron_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_state (
|
||||
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
schedule_json LONGTEXT NULL,
|
||||
last_run_at DATETIME NULL,
|
||||
next_run_at DATETIME NULL,
|
||||
locked_until DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
current_run_id BIGINT UNSIGNED NULL,
|
||||
last_status VARCHAR(32) NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_state_next_run (enabled, next_run_at),
|
||||
KEY idx_cron_task_state_lock (locked_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_runs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(191) NOT NULL,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
actor_user_id INT NULL,
|
||||
scheduled_for DATETIME NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
duration_ms INT UNSIGNED NULL,
|
||||
summary_json LONGTEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_runs_task_created (task_id, created_at),
|
||||
KEY idx_cron_task_runs_status_created (status, created_at),
|
||||
KEY idx_cron_task_runs_module_created (module, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_definition
|
||||
{
|
||||
public string $id;
|
||||
public string $name;
|
||||
public string $description;
|
||||
public string $module;
|
||||
public string $handler;
|
||||
public array $schedule;
|
||||
public bool $enabled;
|
||||
public int $timeout_seconds;
|
||||
public int $estimated_duration_ms;
|
||||
public int $priority;
|
||||
public ?string $legacy_name;
|
||||
|
||||
public function __construct(array $definition)
|
||||
{
|
||||
$this->id = self::requiredString($definition, 'id');
|
||||
$this->name = self::requiredString($definition, 'name');
|
||||
$this->description = (string)($definition['description'] ?? '');
|
||||
$this->module = self::requiredString($definition, 'module');
|
||||
$this->handler = self::requiredString($definition, 'handler');
|
||||
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
|
||||
$this->enabled = (bool)($definition['enabled'] ?? true);
|
||||
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
|
||||
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
|
||||
$this->priority = (int)($definition['priority'] ?? 100);
|
||||
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
|
||||
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
|
||||
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
|
||||
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
|
||||
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
|
||||
}
|
||||
}
|
||||
|
||||
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
|
||||
{
|
||||
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
|
||||
? $state['schedule']
|
||||
: $this->schedule;
|
||||
$enabled = array_key_exists('enabled', $state ?? [])
|
||||
? (bool)$state['enabled']
|
||||
: $this->enabled;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'module' => $this->module,
|
||||
'handler' => $this->handler,
|
||||
'schedule' => $schedule,
|
||||
'default_schedule' => $this->schedule,
|
||||
'enabled' => $enabled,
|
||||
'default_enabled' => $this->enabled,
|
||||
'timeout_seconds' => $this->timeout_seconds,
|
||||
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
|
||||
'priority' => $this->priority,
|
||||
'legacy_name' => $this->legacy_name,
|
||||
'last_run_at' => $state['last_run_at'] ?? null,
|
||||
'next_run_at' => $state['next_run_at'] ?? null,
|
||||
'locked_until' => $state['locked_until'] ?? null,
|
||||
'lock_owner' => $state['lock_owner'] ?? null,
|
||||
'current_run_id' => $state['current_run_id'] ?? null,
|
||||
'last_status' => $state['last_status'] ?? null,
|
||||
'last_error' => $state['last_error'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function requiredString(array $definition, string $key): string
|
||||
{
|
||||
$value = trim((string)($definition[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_registry
|
||||
{
|
||||
private string $modules_root;
|
||||
|
||||
/** @var array<string, cron_task_definition>|null */
|
||||
private ?array $definitions = null;
|
||||
|
||||
public function __construct(?string $modules_root = null)
|
||||
{
|
||||
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, cron_task_definition>
|
||||
*/
|
||||
public function definitions(): array
|
||||
{
|
||||
if ($this->definitions !== null) {
|
||||
return $this->definitions;
|
||||
}
|
||||
|
||||
$definitions = [];
|
||||
foreach ($this->definitionFiles() as $file) {
|
||||
$module_definitions = require $file;
|
||||
if (!is_array($module_definitions)) {
|
||||
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
|
||||
}
|
||||
|
||||
foreach ($module_definitions as $definition) {
|
||||
$task = new cron_task_definition($definition);
|
||||
if (isset($definitions[$task->id])) {
|
||||
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
|
||||
}
|
||||
$definitions[$task->id] = $task;
|
||||
}
|
||||
}
|
||||
|
||||
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
|
||||
if ($left->priority !== $right->priority) {
|
||||
return $left->priority <=> $right->priority;
|
||||
}
|
||||
return strcmp($left->id, $right->id);
|
||||
});
|
||||
|
||||
$this->definitions = $definitions;
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
public function get(string $id_or_legacy_name): ?cron_task_definition
|
||||
{
|
||||
$normalized = trim($id_or_legacy_name);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$definitions = $this->definitions();
|
||||
if (isset($definitions[$normalized])) {
|
||||
return $definitions[$normalized];
|
||||
}
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function definitionFiles(): array
|
||||
{
|
||||
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,10 @@ class customer_mass_import_service
|
||||
if ($cvrLength < 8 || $cvrLength > 20) {
|
||||
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
|
||||
}
|
||||
|
||||
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
|
||||
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizePositiveInt(mixed $value): ?int
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
if ($message !== null) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$context = self::loadOrderProductContext($orderId, $productId);
|
||||
if ($context === null) {
|
||||
return null;
|
||||
}
|
||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||
? self::ONLY_TANKCLEANING_MESSAGE
|
||||
: null;
|
||||
}
|
||||
|
||||
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||
{
|
||||
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||
}
|
||||
|
||||
public static function isTankCleaningProductRow(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1 || $productId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
o.customer_id AS customer_number,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name,
|
||||
p.category AS product_category,
|
||||
c.name AS category_name,
|
||||
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||
FROM orders o
|
||||
LEFT JOIN products p ON p.id = {$productId}
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||
WHERE o.id = {$orderId}
|
||||
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
));
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
private const ADDON_CATEGORY_ID = 4;
|
||||
private const TANK_CLEANING_CATEGORY_ID = 5;
|
||||
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}|null
|
||||
*/
|
||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||
{
|
||||
$order = (new orders_o())->getOrderById($orderId);
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = (new products_o())->getProductById($productId);
|
||||
if (!$product->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
|
||||
if (!$customer->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$categoryId = (int)$product->category->value();
|
||||
$categoryName = $this->categoryName($categoryId);
|
||||
$searchableProduct = $this->searchableProductText($product, $categoryName);
|
||||
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
||||
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
||||
return $this->violation('restrictAdditionalServices');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
return $this->violation('restrictTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
return $this->violation('onlyTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictSpotFree')
|
||||
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
|
||||
return $this->violation('restrictSpotFree');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
|
||||
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
|
||||
return $this->violation('restrictInteriorCleaning');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}
|
||||
*/
|
||||
private function violation(string $rule): array
|
||||
{
|
||||
return [
|
||||
'rule' => $rule,
|
||||
'message' => self::BLOCK_MESSAGE,
|
||||
];
|
||||
}
|
||||
|
||||
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($relatedItemId !== null && $relatedItemId > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->countStandaloneOrderItems($orderId) > 0;
|
||||
}
|
||||
|
||||
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
|
||||
}
|
||||
|
||||
private function isSpotFreeProduct(int $productId, string $searchableProduct): bool
|
||||
{
|
||||
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
|
||||
}
|
||||
|
||||
private function searchableProductText(products_o $product, string $categoryName): string
|
||||
{
|
||||
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function containsAny(string $value, array $terms): bool
|
||||
{
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($value, $term)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function categoryName(int $categoryId): string
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return strtolower((string)($row['name'] ?? ''));
|
||||
}
|
||||
|
||||
private function countStandaloneOrderItems(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT COUNT(*) AS item_count
|
||||
FROM order_items
|
||||
WHERE order_id = ' . $orderId . '
|
||||
AND deleted_at IS NULL
|
||||
AND (related_item_id IS NULL OR related_item_id = 0)'
|
||||
);
|
||||
if (!$result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)($row['item_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for department-scoped customer price overrides.
|
||||
*/
|
||||
class department_customer_price_overrides_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`product_or_category_id` VARCHAR(191) NOT NULL,
|
||||
`percentage` INT NOT NULL DEFAULT 0,
|
||||
`fixed_price` INT NULL DEFAULT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
|
||||
KEY `idx_department_customer_price_overrides_department` (`department_id`),
|
||||
KEY `idx_department_customer_price_overrides_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\department_customer_price_overrides_o;
|
||||
use objects\departments_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class department_customer_pricing_service
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getPricing(int $departmentId, int $userId): array
|
||||
{
|
||||
$department = $this->department($departmentId);
|
||||
$customer = $this->customer($userId);
|
||||
$this->assertEnabled($department);
|
||||
|
||||
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
|
||||
|
||||
return [
|
||||
'department' => $department,
|
||||
'customer' => $customer,
|
||||
'overrides' => $overrides,
|
||||
'categories' => $this->catalog($departmentId, $customer['id']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updatePricing(int $departmentId, int $userId, array $payload): array
|
||||
{
|
||||
$department = $this->department($departmentId);
|
||||
$customer = $this->customer($userId);
|
||||
$this->assertEnabled($department);
|
||||
|
||||
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
|
||||
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
|
||||
}
|
||||
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
|
||||
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
|
||||
}
|
||||
|
||||
$overrides = $payload['overrides'] ?? null;
|
||||
if (!is_array($overrides)) {
|
||||
throw new limited_backoffice_exception('Overrides are required.', 400);
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeOverrides($departmentId, $overrides);
|
||||
$overrideObject = new department_customer_price_overrides_o();
|
||||
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
|
||||
$normalizedKeys = [];
|
||||
foreach ($normalized as $override) {
|
||||
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
$db->query(
|
||||
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
|
||||
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
|
||||
);
|
||||
|
||||
foreach ($normalized as $override) {
|
||||
$overrideObject->setPrice(
|
||||
$departmentId,
|
||||
$customer['id'],
|
||||
(bool)$override['is_category'],
|
||||
$override['product_or_category_id'],
|
||||
(int)$override['percentage'],
|
||||
$override['fixed_price']
|
||||
);
|
||||
}
|
||||
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable) {
|
||||
$db->conn()->rollback();
|
||||
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
|
||||
}
|
||||
|
||||
foreach ($normalized as $override) {
|
||||
$this->recordVersion($customer, $departmentId, $override);
|
||||
}
|
||||
|
||||
foreach ($existingOverrides as $existingOverride) {
|
||||
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
|
||||
if (isset($normalizedKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->recordVersion($customer, $departmentId, [
|
||||
'is_category' => (bool)$existingOverride['is_category'],
|
||||
'product_or_category_id' => $existingOverride['product_or_category_id'],
|
||||
'percentage' => 0,
|
||||
'fixed_price' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->getPricing($departmentId, $customer['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
|
||||
*/
|
||||
private function department(int $departmentId): array
|
||||
{
|
||||
$department = (new departments_o())->getDepartmentById($departmentId);
|
||||
if (!is_array($department) || empty($department)) {
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'description' => (string)($department['description'] ?? ''),
|
||||
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,customer_number:int,display_name:string}
|
||||
*/
|
||||
private function customer(int $userId): array
|
||||
{
|
||||
$customer = (new users_o())->getUserById($userId);
|
||||
if (!$customer->exists()) {
|
||||
throw new limited_backoffice_exception('Customer not found', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$customer->id,
|
||||
'customer_number' => (int)$customer->customer_number->value(),
|
||||
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $department
|
||||
*/
|
||||
private function assertEnabled(array $department): void
|
||||
{
|
||||
if (!($department['custom_pricing_only'] ?? false)) {
|
||||
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
|
||||
'message' => 'Department customer pricing is disabled.',
|
||||
'code' => 'department_customer_pricing_disabled',
|
||||
'department' => $department,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function catalog(int $departmentId, int $userId): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
c.`id` AS `category_id`,
|
||||
c.`name` AS `category_name`,
|
||||
c.`description` AS `category_description`,
|
||||
p.*,
|
||||
pdp.`price` AS `department_price`
|
||||
FROM `department_categories` dc
|
||||
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
|
||||
INNER JOIN `products` p ON p.`category` = dc.`category_id`
|
||||
LEFT JOIN `product_department_prices` pdp
|
||||
ON pdp.`department_id` = dc.`department_id`
|
||||
AND pdp.`product_id` = p.`id`
|
||||
WHERE dc.`department_id` = " . (int)$departmentId . "
|
||||
AND dc.`deleted_at` IS NULL
|
||||
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$rows = $result ? $db->fetch_all($result) : [];
|
||||
$customer = (new users_o())->getUserById($userId);
|
||||
$categories = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$productId = (int)$row['id'];
|
||||
if (isset($seen[$productId])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$productId] = true;
|
||||
|
||||
$categoryId = (int)$row['category_id'];
|
||||
if (!isset($categories[$categoryId])) {
|
||||
$categories[$categoryId] = [
|
||||
'id' => $categoryId,
|
||||
'name' => (string)$row['category_name'],
|
||||
'description' => (string)($row['category_description'] ?? ''),
|
||||
'products' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
|
||||
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
if ($departmentPrice !== null) {
|
||||
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
|
||||
}
|
||||
|
||||
$categories[$categoryId]['products'][] = [
|
||||
'id' => $productId,
|
||||
'name' => (string)$row['name'],
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'category' => $categoryId,
|
||||
'apply_category_discount' => (bool)$row['apply_category_discount'],
|
||||
'base_price' => (int)$row['price'],
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_price' => $effectivePrice,
|
||||
'missing_department_price' => $departmentPrice === null,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $overrides
|
||||
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
|
||||
*/
|
||||
private function normalizeOverrides(int $departmentId, array $overrides): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($overrides as $override) {
|
||||
if (!is_array($override)) {
|
||||
throw new limited_backoffice_exception('Invalid override payload.', 400);
|
||||
}
|
||||
|
||||
$isCategory = (bool)($override['is_category'] ?? false);
|
||||
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
|
||||
if ($objectId === null || $objectId === '') {
|
||||
throw new limited_backoffice_exception('Override object is required.', 400);
|
||||
}
|
||||
|
||||
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($percentage === false || $percentage < 0 || $percentage > 100) {
|
||||
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
|
||||
}
|
||||
|
||||
$fixedPrice = null;
|
||||
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
|
||||
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
|
||||
if ($fixedPrice === false || $fixedPrice < 0) {
|
||||
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if ($isCategory) {
|
||||
$fixedPrice = null;
|
||||
$objectId = (string)$objectId;
|
||||
if ($objectId !== 'global') {
|
||||
$this->assertDepartmentCategory($departmentId, $objectId);
|
||||
}
|
||||
} else {
|
||||
$objectId = (int)$objectId;
|
||||
$this->assertDepartmentProduct($departmentId, $objectId);
|
||||
}
|
||||
|
||||
if ($percentage <= 0 && $fixedPrice === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->overrideKey($isCategory, $objectId);
|
||||
$normalized[$key] = [
|
||||
'is_category' => $isCategory,
|
||||
'product_or_category_id' => $objectId,
|
||||
'percentage' => (int)$percentage,
|
||||
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int,customer_number:int,display_name:string} $customer
|
||||
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
|
||||
*/
|
||||
private function recordVersion(array $customer, int $departmentId, array $override): void
|
||||
{
|
||||
try {
|
||||
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
|
||||
(int)$customer['id'],
|
||||
(int)$customer['customer_number'],
|
||||
(bool)$override['is_category'],
|
||||
(string)$override['product_or_category_id'],
|
||||
(int)$override['percentage'],
|
||||
date('Y-m-d H:i:s'),
|
||||
'live.department_discount_override.route',
|
||||
1.0,
|
||||
false,
|
||||
[
|
||||
'route' => 'department_customer_pricing',
|
||||
'department_id' => $departmentId,
|
||||
],
|
||||
$override['fixed_price'],
|
||||
$departmentId
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function overrideKey(bool $isCategory, int|string $objectId): string
|
||||
{
|
||||
return ((int)$isCategory) . ':' . (string)$objectId;
|
||||
}
|
||||
|
||||
private function assertDepartmentProduct(int $departmentId, int $productId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT p.`id`
|
||||
FROM `department_categories` dc
|
||||
INNER JOIN `products` p ON p.`category` = dc.`category_id`
|
||||
WHERE dc.`department_id` = ' . (int)$departmentId . '
|
||||
AND dc.`deleted_at` IS NULL
|
||||
AND p.`id` = ' . (int)$productId . '
|
||||
LIMIT 1'
|
||||
);
|
||||
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Product is not available for this department.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$categoryId = $db->escape_string($categoryId);
|
||||
$result = $db->query(
|
||||
"SELECT `id`
|
||||
FROM `department_categories`
|
||||
WHERE `department_id` = " . (int)$departmentId . "
|
||||
AND `deleted_at` IS NULL
|
||||
AND `category_id` = '{$categoryId}'
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Category is not available for this department.', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
||||
|
||||
use Exception;
|
||||
|
||||
class department_wash_count_service
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
|
||||
{
|
||||
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$total += (int)($row['wash_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
|
||||
|
||||
$sql = "SELECT deduped.department_id,
|
||||
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
COUNT(*) AS wash_count
|
||||
FROM (
|
||||
SELECT dedupe_key,
|
||||
department_id,
|
||||
MIN(counted_at) AS counted_at
|
||||
FROM ($candidate_sql) candidates
|
||||
GROUP BY dedupe_key, department_id
|
||||
) deduped
|
||||
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
|
||||
ORDER BY deduped.department_id ASC, hour_bucket ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
|
||||
'wash_count' => (int)($row['wash_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array{quantity:int,products:int,earnings:int,washes:int}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [
|
||||
'quantity' => 0,
|
||||
'products' => 0,
|
||||
'earnings' => 0,
|
||||
'washes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
|
||||
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
|
||||
COALESCE(SUM(oi.quantity), 0) AS products,
|
||||
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
WHERE o.department_id IN ($department_ids_sql)
|
||||
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$row = is_object($result) ? $result->fetch_assoc() : null;
|
||||
|
||||
return [
|
||||
'quantity' => (int)($row['quantity'] ?? 0),
|
||||
'products' => (int)($row['products'] ?? 0),
|
||||
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
|
||||
'washes' => $this->countRows($date_start, $date_end, $normalized_department_ids),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array<int,array{id:int,department_id:int,created_at:string}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
|
||||
|
||||
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
|
||||
department_id,
|
||||
MIN(counted_at) AS created_at
|
||||
FROM ($candidate_sql) candidates
|
||||
GROUP BY dedupe_key, department_id
|
||||
ORDER BY created_at ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $department_ids
|
||||
* @throws Exception
|
||||
*/
|
||||
private function countRows(string $date_start, string $date_end, array $department_ids): int
|
||||
{
|
||||
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$total += (int)($row['wash_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
|
||||
{
|
||||
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
|
||||
o.id AS entity_id,
|
||||
o.department_id,
|
||||
o.created_at AS counted_at,
|
||||
0 AS source_priority
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE o.department_id IN ($department_ids_sql)
|
||||
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL
|
||||
AND p.is_wash = 1
|
||||
UNION ALL
|
||||
SELECT CASE
|
||||
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
|
||||
ELSE CONCAT('selfserve:', s.id)
|
||||
END AS dedupe_key,
|
||||
CASE
|
||||
WHEN linked_o.id IS NOT NULL THEN linked_o.id
|
||||
ELSE s.id
|
||||
END AS entity_id,
|
||||
COALESCE(linked_o.department_id, s.department_id) AS department_id,
|
||||
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
|
||||
1 AS source_priority
|
||||
FROM selfserve_wash_sessions s
|
||||
LEFT JOIN orders linked_o
|
||||
ON linked_o.id = s.order_id
|
||||
AND linked_o.deleted_at IS NULL
|
||||
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
|
||||
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND s.deleted_at IS NULL
|
||||
AND s.completed_at IS NOT NULL
|
||||
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $ids
|
||||
* @return array<int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($ids as $id) {
|
||||
$value = (int)$id;
|
||||
if ($value > 0) {
|
||||
$normalized[$value] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function validateDateRange(string $date_start, string $date_end): void
|
||||
{
|
||||
if (strtotime($date_start) === false || strtotime($date_end) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
if (strtotime($date_start) > strtotime($date_end)) {
|
||||
throw new Exception('The start date cannot be after the end date');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
||||
AFTER archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
|
||||
@@ -172,7 +172,8 @@ class economic implements economic_i
|
||||
string $email,
|
||||
int $phone,
|
||||
?int $mobile_phone = null,
|
||||
object|array|null $company_information = null
|
||||
object|array|null $company_information = null,
|
||||
?string $ean = null
|
||||
): object
|
||||
{
|
||||
$payload = [
|
||||
@@ -196,10 +197,37 @@ class economic implements economic_i
|
||||
];
|
||||
|
||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||
$normalized_ean = self::normalizeCustomerEan($ean);
|
||||
if ($normalized_ean !== null) {
|
||||
$payload['ean'] = $normalized_ean;
|
||||
}
|
||||
|
||||
return $this->customers->customers->create($payload);
|
||||
}
|
||||
|
||||
public static function normalizeCustomerEan(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', (string)$value);
|
||||
if (!is_string($digits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = trim($digits);
|
||||
if ($digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($digits) > 13) {
|
||||
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
|
||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||
{
|
||||
if ($company_information === null) {
|
||||
|
||||
@@ -240,7 +240,13 @@ class economic_transfer_executor
|
||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||
);
|
||||
|
||||
return $collected_order_invoices->asArray();
|
||||
$result = $collected_order_invoices->asArray();
|
||||
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
||||
if ($transfer_metrics !== null) {
|
||||
$result['economic_transfer_metrics'] = $transfer_metrics;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
|
||||
|
||||
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
|
||||
{
|
||||
department_customer_price_overrides_schema_bootstrap::ensureTables();
|
||||
$this->versioning = $versioning ?? new economic_v2_versioning_service();
|
||||
$this->economic = $economic;
|
||||
}
|
||||
@@ -388,7 +389,16 @@ class economic_v2_distribution_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id);
|
||||
if ($discount_row === null) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||
$fixed_price = (float)$discount_row['fixed_price'];
|
||||
$order_discount_total += (($base_price - $fixed_price) * $quantity);
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage <= 0) {
|
||||
continue;
|
||||
@@ -1519,7 +1529,13 @@ class economic_v2_distribution_service
|
||||
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
|
||||
}
|
||||
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id);
|
||||
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
|
||||
$total += $line_price;
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage > 0) {
|
||||
$line_price *= (1 - ($discount_percentage / 100));
|
||||
@@ -1529,15 +1545,22 @@ class economic_v2_distribution_service
|
||||
return $total;
|
||||
}
|
||||
|
||||
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
|
||||
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
|
||||
{
|
||||
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
|
||||
$cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . substr($timestamp, 0, 19);
|
||||
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
|
||||
return $this->discount_resolution_cache[$cache_key];
|
||||
}
|
||||
|
||||
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
|
||||
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
|
||||
$scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
|
||||
? (int)$department_id
|
||||
: null;
|
||||
|
||||
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId);
|
||||
if ($direct !== null && (
|
||||
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|
||||
|| (int)($direct['discount'] ?? 0) > 0
|
||||
)) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $direct;
|
||||
}
|
||||
|
||||
@@ -1545,13 +1568,20 @@ class economic_v2_distribution_service
|
||||
if ($product !== null) {
|
||||
$category = (string)$product->category->value();
|
||||
if ($category !== '') {
|
||||
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
|
||||
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId);
|
||||
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $category_discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($scopedDepartmentId !== null) {
|
||||
$global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId);
|
||||
if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $global_discount;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->discount_resolution_cache[$cache_key] = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,11 +60,13 @@ class economic_v2_schema_bootstrap
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NULL DEFAULT NULL,
|
||||
user_id INT NOT NULL,
|
||||
customer_number INT NOT NULL,
|
||||
is_category TINYINT(1) NOT NULL,
|
||||
object_id VARCHAR(64) NOT NULL,
|
||||
discount INT NOT NULL,
|
||||
fixed_price INT NULL DEFAULT NULL,
|
||||
effective_from DATETIME NOT NULL,
|
||||
effective_to DATETIME NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'live',
|
||||
@@ -74,6 +76,7 @@ class economic_v2_schema_bootstrap
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
|
||||
INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to),
|
||||
INDEX idx_discount_override_versions_user (user_id),
|
||||
INDEX idx_discount_override_versions_source (source)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
|
||||
$db->query(
|
||||
"ALTER TABLE customer_discount_override_versions
|
||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
||||
AFTER discount"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) {
|
||||
$db->query(
|
||||
"ALTER TABLE customer_discount_override_versions
|
||||
ADD COLUMN department_id INT NULL DEFAULT NULL
|
||||
AFTER id"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
|
||||
string $source = 'live.discount_override',
|
||||
float $confidence = 1.0,
|
||||
bool $inferred = false,
|
||||
array $metadata = []
|
||||
array $metadata = [],
|
||||
?int $fixed_price = null,
|
||||
?int $department_id = null
|
||||
): array {
|
||||
$identity = [
|
||||
'user_id' => $user_id,
|
||||
@@ -143,8 +145,11 @@ class economic_v2_versioning_service
|
||||
'is_category' => (int)$is_category,
|
||||
'object_id' => (string)$object_id,
|
||||
];
|
||||
if ($department_id !== null) {
|
||||
$identity['department_id'] = (int)$department_id;
|
||||
}
|
||||
|
||||
if ($discount === null || (int)$discount === 0) {
|
||||
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
|
||||
return $this->closeActiveVersion(
|
||||
'customer_discount_override_versions',
|
||||
$identity,
|
||||
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
|
||||
$identity,
|
||||
[
|
||||
'discount' => (int)$discount,
|
||||
'fixed_price' => $is_category ? null : $fixed_price,
|
||||
],
|
||||
$this->normalizeDatetime($effective_from),
|
||||
$source,
|
||||
@@ -238,15 +244,21 @@ class economic_v2_versioning_service
|
||||
int $customer_number,
|
||||
bool $is_category,
|
||||
int|string $object_id,
|
||||
string $timestamp
|
||||
string $timestamp,
|
||||
?int $department_id = null
|
||||
): ?array {
|
||||
$identity = [
|
||||
'customer_number' => $customer_number,
|
||||
'is_category' => (int)$is_category,
|
||||
'object_id' => (string)$object_id,
|
||||
];
|
||||
if ($department_id !== null) {
|
||||
$identity['department_id'] = (int)$department_id;
|
||||
}
|
||||
|
||||
$rows = $this->resolveActiveVersions(
|
||||
'customer_discount_override_versions',
|
||||
[
|
||||
'customer_number' => $customer_number,
|
||||
'is_category' => (int)$is_category,
|
||||
'object_id' => (string)$object_id,
|
||||
],
|
||||
$identity,
|
||||
$timestamp,
|
||||
'effective_from DESC, id DESC',
|
||||
1
|
||||
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
|
||||
}
|
||||
|
||||
// Discount overrides current state.
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
|
||||
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
|
||||
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
|
||||
($has_override_fixed_price ? ', po.fixed_price' : '') .
|
||||
($has_override_created_at ? ', po.created_at' : '');
|
||||
$discount_rows = $this->fetchAll(
|
||||
"SELECT $discount_cols
|
||||
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
|
||||
'backfill.current_discount_override',
|
||||
$confidence,
|
||||
true,
|
||||
['table' => 'price_overrides']
|
||||
['table' => 'price_overrides'],
|
||||
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
|
||||
);
|
||||
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
|
||||
}
|
||||
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
|
||||
$bucket[$action]++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
||||
{
|
||||
if (self::isFakeDeliveryEnabled()) {
|
||||
self::$fake_deliveries[] = [
|
||||
self::recordFakeDelivery([
|
||||
'to' => $to,
|
||||
'recipient_name' => $recipient_name,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'html' => $html,
|
||||
];
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
public static function resetFakeDeliveries(): void
|
||||
{
|
||||
self::$fake_deliveries = [];
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path !== null && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function syncFakeDeliveries(): void
|
||||
{
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null || !is_file($path)) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$deliveries = [];
|
||||
foreach ($lines as $line) {
|
||||
$delivery = json_decode($line, true);
|
||||
if (is_array($delivery)) {
|
||||
$deliveries[] = $delivery;
|
||||
}
|
||||
}
|
||||
|
||||
self::$fake_deliveries = $deliveries;
|
||||
}
|
||||
|
||||
private static function recordFakeDelivery(array $delivery): void
|
||||
{
|
||||
self::$fake_deliveries[] = $delivery;
|
||||
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private static function getFakeDeliveriesPath(): ?string
|
||||
{
|
||||
if (!self::isFakeDeliveryEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
|
||||
if ($configuredPath !== '') {
|
||||
return $configuredPath;
|
||||
}
|
||||
|
||||
if (getenv('RUN_API_TESTS') !== '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
|
||||
. DIRECTORY_SEPARATOR
|
||||
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
|
||||
}
|
||||
|
||||
private static function isFakeDeliveryEnabled(): bool
|
||||
@@ -511,4 +577,47 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
$this->attachments
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
if (!$customer->exists()) {
|
||||
throw new Exception('Customer not found with customer number: ' . $customer_number);
|
||||
}
|
||||
|
||||
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
|
||||
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
|
||||
$safeCustomerNumber = (int)$customer->customer_number->value();
|
||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
||||
$message = "
|
||||
<p>A new customer has registered on truckwash.io.</p>
|
||||
<p>
|
||||
<strong>Customer number:</strong> $safeCustomerNumber<br>
|
||||
<strong>Customer name:</strong> $safeCustomerName
|
||||
</p>
|
||||
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
|
||||
";
|
||||
|
||||
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
|
||||
$recipientEmail = trim((string)($recipient['email'] ?? ''));
|
||||
if ($recipientEmail === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recipientName = trim((string)($recipient['display_name'] ?? ''));
|
||||
if ($recipientName === '') {
|
||||
$recipientName = $recipientEmail;
|
||||
}
|
||||
|
||||
$this->sendEmail(
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
'New customer registered on Truck Wash',
|
||||
$message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,12 +92,17 @@ class error_report_service
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
$runtimeContext['screenshot_attachment'] = [
|
||||
'status' => $storedScreenshot['status'],
|
||||
'attached' => $storedScreenshot['key'] !== '',
|
||||
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
|
||||
'size_bytes' => (int)$storedScreenshot['size_bytes'],
|
||||
];
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
@@ -295,6 +300,67 @@ class error_report_service
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function storeOptionalScreenshot(mixed $value, array $context): array
|
||||
{
|
||||
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
|
||||
return $this->emptyScreenshotAttachment('invalid');
|
||||
}
|
||||
|
||||
$dataUri = trim((string)($value ?? ''));
|
||||
if ($dataUri === '') {
|
||||
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
|
||||
}
|
||||
|
||||
try {
|
||||
$screenshot = self::decodeScreenshotDataUri($dataUri);
|
||||
} catch (RuntimeException $exception) {
|
||||
$message = strtolower($exception->getMessage());
|
||||
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
} catch (Throwable) {
|
||||
return $this->emptyScreenshotAttachment('storage_failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => (string)($storedScreenshot['key'] ?? ''),
|
||||
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
|
||||
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
|
||||
'status' => 'stored',
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyScreenshotAttachment(string $status): array
|
||||
{
|
||||
return [
|
||||
'key' => '',
|
||||
'mime_type' => '',
|
||||
'size_bytes' => 0,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
private function contextScreenshotStatus(array $context): ?string
|
||||
{
|
||||
$attachment = $context['screenshot_attachment'] ?? null;
|
||||
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
|
||||
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
|
||||
|
||||
return $this->normalizeEmptyScreenshotStatus($status);
|
||||
}
|
||||
|
||||
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
@@ -432,6 +498,10 @@ class error_report_service
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
|
||||
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
|
||||
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
|
||||
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
@@ -449,10 +519,10 @@ class error_report_service
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'screenshot' => $hasScreenshot ? [
|
||||
'mime_type' => $screenshotMimeType,
|
||||
'size_bytes' => $screenshotSizeBytes,
|
||||
] : null,
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
@@ -467,8 +537,11 @@ class error_report_service
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
if ($hasScreenshot) {
|
||||
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
||||
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
||||
}
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
|
||||
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
return match ($endpoint) {
|
||||
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
|
||||
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
|
||||
'/v2/devices/api/batch/get' => $this->handleBatchGetStates($department_id, $data),
|
||||
'/v2/devices/api/batch/set/switch' => $this->handleBatchSetSwitch($department_id, $data),
|
||||
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
|
||||
};
|
||||
}
|
||||
@@ -95,6 +97,52 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function handleBatchGetStates(int $departmentId, array $data): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ((array)($data['commands'] ?? $data['targets'] ?? []) as $entry) {
|
||||
$command = is_array($entry) ? $entry : ['relay_id' => $entry];
|
||||
$relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? $command['id'] ?? ''));
|
||||
if ($relayId === '') {
|
||||
continue;
|
||||
}
|
||||
$requests[] = [
|
||||
'target' => strtoupper(trim((string)($command['target'] ?? $relayId))),
|
||||
'relay_id' => $relayId,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->manager()->queueRelayStatusBatch($departmentId, $requests, null, $this->localOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function handleBatchSetSwitch(int $departmentId, array $data): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ((array)($data['commands'] ?? []) as $entry) {
|
||||
if (!is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
$relayId = trim((string)($entry['relay_id'] ?? $entry['relayId'] ?? $entry['id'] ?? ''));
|
||||
if ($relayId === '') {
|
||||
continue;
|
||||
}
|
||||
$requests[] = [
|
||||
'target' => strtoupper(trim((string)($entry['target'] ?? $relayId))),
|
||||
'relay_id' => $relayId,
|
||||
'on' => (bool)($entry['on'] ?? false),
|
||||
'toggle_after' => $entry['toggle_after'] ?? $entry['toggleAfter'] ?? $entry['timer'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->manager()->queueRelaySwitchBatch($departmentId, $requests, null, $this->localOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $status
|
||||
* @return array<string,mixed>
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use objects\collected_order_invoices_o;
|
||||
use objects\logs_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class invoice_collection_bulk_action_service
|
||||
{
|
||||
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
|
||||
public const ACTION_MERGE = 'merge_collections';
|
||||
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
|
||||
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
|
||||
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
|
||||
|
||||
private const PREVIEW_TTL_SECONDS = 600;
|
||||
private const MAX_COLLECTIONS = 100;
|
||||
private const CONFIRMATION_PHRASES = [
|
||||
'da' => 'Bekræft',
|
||||
'en' => 'Confirm',
|
||||
'sv' => 'Bekräfta',
|
||||
'no' => 'Bekreft',
|
||||
'de' => 'Bestätigen',
|
||||
];
|
||||
|
||||
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
|
||||
{
|
||||
$action = $this->normalizeAction($action);
|
||||
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
||||
$options = $this->normalizeOptions($options);
|
||||
|
||||
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
|
||||
$previewId = $this->previewId();
|
||||
$preview['preview_id'] = $previewId;
|
||||
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
||||
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
|
||||
|
||||
$this->cachePreview($previewId, [
|
||||
'action' => $action,
|
||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'options' => $options,
|
||||
'locale' => $locale,
|
||||
'selection_hash' => $preview['selection_hash'],
|
||||
'preview' => $preview,
|
||||
]);
|
||||
|
||||
return $preview;
|
||||
}
|
||||
|
||||
public function apply(
|
||||
string $previewId,
|
||||
string $action,
|
||||
array $invoiceCollectionIds,
|
||||
array $options,
|
||||
string $confirmationText,
|
||||
int $actorUserId,
|
||||
string $locale = 'da'
|
||||
): array {
|
||||
global $db;
|
||||
|
||||
$action = $this->normalizeAction($action);
|
||||
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
||||
$options = $this->normalizeOptions($options);
|
||||
$cached = $this->getCachedPreview($previewId);
|
||||
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
||||
|
||||
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
|
||||
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
|
||||
}
|
||||
|
||||
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
|
||||
if (trim($confirmationText) !== $expectedConfirmation) {
|
||||
throw new Exception('Confirmation text does not match.');
|
||||
}
|
||||
|
||||
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
|
||||
if (!empty($freshPreview['blockers'])) {
|
||||
throw new Exception('Action cannot be applied while blockers are present.');
|
||||
}
|
||||
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
$result = match ($action) {
|
||||
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
|
||||
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
|
||||
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
|
||||
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
|
||||
self::ACTION_QUEUE_ECONOMIC => [
|
||||
'queued_invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'changed_count' => count($invoiceCollectionIds),
|
||||
],
|
||||
default => throw new Exception('Unsupported action'),
|
||||
};
|
||||
(new logs_o())->add(
|
||||
'orderInvoices',
|
||||
'global',
|
||||
1,
|
||||
$actorUserId,
|
||||
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
|
||||
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
|
||||
);
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->conn()->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->deleteCachedPreview($previewId);
|
||||
|
||||
return [
|
||||
...$freshPreview,
|
||||
'preview' => false,
|
||||
'result' => $result,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
|
||||
{
|
||||
$collections = $this->loadCollections($invoiceCollectionIds);
|
||||
$base = [
|
||||
'action' => $action,
|
||||
'preview' => true,
|
||||
'confirmation_phrase' => $this->confirmationPhrase($locale),
|
||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
|
||||
'warnings' => [],
|
||||
'blockers' => [],
|
||||
];
|
||||
|
||||
return match ($action) {
|
||||
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
|
||||
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
|
||||
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
|
||||
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
|
||||
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
|
||||
default => throw new Exception('Unsupported action'),
|
||||
};
|
||||
}
|
||||
|
||||
private function previewCleanCustomerRules(array $preview, array $collections): array
|
||||
{
|
||||
$items = [];
|
||||
$blockers = [];
|
||||
foreach ($collections as $collection) {
|
||||
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||
$rows = $this->orderItemRows((int)$collection->id);
|
||||
$violatingItemIds = [];
|
||||
$includedItemIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
|
||||
(int)$row['order_id'],
|
||||
(int)$row['product_id'],
|
||||
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
|
||||
);
|
||||
if ($violation === null) {
|
||||
continue;
|
||||
}
|
||||
$violatingItemIds[] = (int)$row['order_item_id'];
|
||||
$includedItemIds[] = (int)$row['order_item_id'];
|
||||
$items[] = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'order_id' => (int)$row['order_id'],
|
||||
'order_item_id' => (int)$row['order_item_id'],
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'product_name' => (string)$row['product_name'],
|
||||
'rule' => (string)$violation['rule'],
|
||||
'price' => (int)$row['price'],
|
||||
'quantity' => (int)$row['quantity'],
|
||||
'will_soft_delete' => true,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$orderItemId = (int)$row['order_item_id'];
|
||||
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
|
||||
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$includedItemIds[] = $orderItemId;
|
||||
$items[] = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'order_id' => (int)$row['order_id'],
|
||||
'order_item_id' => $orderItemId,
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'product_name' => (string)$row['product_name'],
|
||||
'rule' => 'related_to_removed_item',
|
||||
'price' => (int)$row['price'],
|
||||
'quantity' => (int)$row['quantity'],
|
||||
'will_soft_delete' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'order_items' => $items,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'order_items' => count($items),
|
||||
'changed_count' => count($items),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function previewMerge(array $preview, array $collections, array $options): array
|
||||
{
|
||||
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
||||
$blockers = [];
|
||||
if (count($collections) < 2) {
|
||||
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
|
||||
}
|
||||
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
|
||||
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
|
||||
}
|
||||
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
|
||||
if (count($customerNumbers) !== 1) {
|
||||
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
|
||||
}
|
||||
foreach ($collections as $collection) {
|
||||
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||
}
|
||||
|
||||
$ordersToMove = [];
|
||||
foreach ($collections as $collection) {
|
||||
if ((int)$collection->id === $targetId) {
|
||||
continue;
|
||||
}
|
||||
foreach ($collection->getOrderIds() as $orderIdRow) {
|
||||
$ordersToMove[] = [
|
||||
'order_id' => (int)$orderIdRow['id'],
|
||||
'source_invoice_collection_id' => (int)$collection->id,
|
||||
'target_invoice_collection_id' => $targetId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'target_invoice_collection_id' => $targetId,
|
||||
'orders' => $ordersToMove,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'orders_to_move' => count($ordersToMove),
|
||||
'changed_count' => count($ordersToMove),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function previewSplitByMonth(array $preview, array $collections): array
|
||||
{
|
||||
$items = [];
|
||||
$changed = [];
|
||||
$skipped = [];
|
||||
foreach ($collections as $collection) {
|
||||
try {
|
||||
$item = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
...$collection->previewSplitByOrderMonth(),
|
||||
];
|
||||
if (($item['status'] ?? '') === 'changed') {
|
||||
$changed[] = $item;
|
||||
} else {
|
||||
$skipped[] = $item;
|
||||
}
|
||||
$items[] = $item;
|
||||
} catch (\Throwable $e) {
|
||||
$item = [
|
||||
'status' => 'skipped',
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'reason' => 'not_splittable',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
$skipped[] = $item;
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'items' => $items,
|
||||
'changed' => $changed,
|
||||
'skipped' => $skipped,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'changed_count' => count($changed),
|
||||
'skipped_count' => count($skipped),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function previewResetHiddenPrices(array $preview, array $collections): array
|
||||
{
|
||||
$items = [];
|
||||
$blockers = [];
|
||||
foreach ($collections as $collection) {
|
||||
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
|
||||
if ((int)$row['include_in_invoice'] !== 0) {
|
||||
continue;
|
||||
}
|
||||
$order = (new orders_o())->select((int)$row['order_id']);
|
||||
$product = (new products_o())->select((int)$row['product_id']);
|
||||
if (!$order->exists() || !$product->exists()) {
|
||||
continue;
|
||||
}
|
||||
$newPrice = (int)$order->getCustomerProductPrice($product);
|
||||
if ((int)$row['price'] === $newPrice) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'order_id' => (int)$row['order_id'],
|
||||
'order_item_id' => (int)$row['order_item_id'],
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'product_name' => (string)$row['product_name'],
|
||||
'current_price' => (int)$row['price'],
|
||||
'new_price' => $newPrice,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'order_items' => $items,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'order_items' => count($items),
|
||||
'changed_count' => count($items),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function previewQueueEconomic(array $preview, array $collections): array
|
||||
{
|
||||
$blockers = [];
|
||||
foreach ($collections as $collection) {
|
||||
if (!empty($collection->booked_invoice_id->value())) {
|
||||
$blockers[] = [
|
||||
'code' => 'collection_booked',
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'message' => 'Invoice collection is already booked.',
|
||||
];
|
||||
}
|
||||
}
|
||||
return [
|
||||
...$preview,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'changed_count' => count($collections),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyCleanCustomerRules(array $preview): array
|
||||
{
|
||||
global $db;
|
||||
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
|
||||
if ($itemIds === []) {
|
||||
return ['changed_count' => 0, 'order_item_ids' => []];
|
||||
}
|
||||
$ids = implode(',', array_map('intval', $itemIds));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$safeNow = $db->escape_string($now);
|
||||
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
|
||||
$this->touchOrdersForItems($itemIds);
|
||||
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
|
||||
}
|
||||
|
||||
private function applyMerge(array $preview, array $options): array
|
||||
{
|
||||
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
||||
$moved = [];
|
||||
foreach ($preview['orders'] ?? [] as $row) {
|
||||
$order = (new orders_o())->select((int)$row['order_id']);
|
||||
if (!$order->exists()) {
|
||||
continue;
|
||||
}
|
||||
$order->assignToInvoiceCollection($targetId);
|
||||
$moved[] = (int)$order->id;
|
||||
}
|
||||
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
|
||||
}
|
||||
|
||||
private function applySplitByMonth(array $preview): array
|
||||
{
|
||||
$changed = [];
|
||||
$skipped = [];
|
||||
foreach ($preview['items'] ?? [] as $item) {
|
||||
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
|
||||
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
|
||||
$skipped[] = $item;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
|
||||
} catch (\Throwable $e) {
|
||||
$skipped[] = [
|
||||
'invoice_collection_id' => $invoiceCollectionId,
|
||||
'status' => 'skipped',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
private function applyResetHiddenPrices(array $preview): array
|
||||
{
|
||||
$changed = [];
|
||||
foreach ($preview['order_items'] ?? [] as $item) {
|
||||
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
|
||||
if (!$orderItem->exists()) {
|
||||
continue;
|
||||
}
|
||||
$orderItem->price->set((int)$item['new_price']);
|
||||
$orderItem->objectChanged();
|
||||
$changed[] = (int)$orderItem->id;
|
||||
}
|
||||
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
|
||||
}
|
||||
|
||||
private function contentMutationBlockers(collected_order_invoices_o $collection): array
|
||||
{
|
||||
$blockers = [];
|
||||
if (!empty($collection->booked_invoice_id->value())) {
|
||||
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
|
||||
}
|
||||
if (!empty($collection->external_id->value())) {
|
||||
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
|
||||
}
|
||||
return $blockers;
|
||||
}
|
||||
|
||||
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
|
||||
{
|
||||
global $db;
|
||||
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
|
||||
$sql = "
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
oi.product_id,
|
||||
oi.related_item_id,
|
||||
oi.include_in_invoice,
|
||||
oi.price,
|
||||
oi.quantity,
|
||||
p.name AS product_name
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE o.invoice_collection_id = {$invoiceCollectionId}
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL
|
||||
{$hiddenCondition}
|
||||
ORDER BY o.id ASC, oi.id ASC
|
||||
";
|
||||
$result = $db->query($sql);
|
||||
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
private function touchOrdersForItems(array $itemIds): void
|
||||
{
|
||||
global $db;
|
||||
if ($itemIds === []) {
|
||||
return;
|
||||
}
|
||||
$ids = implode(',', array_map('intval', $itemIds));
|
||||
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
|
||||
if (!$result) {
|
||||
return;
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$order = (new orders_o())->select((int)$row['order_id']);
|
||||
if ($order->exists()) {
|
||||
$order->objectChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function touchCollections(array $invoiceCollectionIds): void
|
||||
{
|
||||
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
||||
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
|
||||
if ($collection->exists()) {
|
||||
$collection->objectChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadCollections(array $invoiceCollectionIds): array
|
||||
{
|
||||
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
|
||||
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
||||
$collection->requireSelected();
|
||||
return $collection;
|
||||
}, $invoiceCollectionIds);
|
||||
}
|
||||
|
||||
private function collectionSummary(collected_order_invoices_o $collection): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$collection->id,
|
||||
'customer_number' => (int)$collection->customer_number->value(),
|
||||
'name' => (string)$collection->name->value(),
|
||||
'created_at' => (string)$collection->created_at->value(),
|
||||
'closed_at' => $collection->closed_at->value(),
|
||||
'booked_invoice_id' => $collection->booked_invoice_id->value(),
|
||||
'external_id' => $collection->external_id->value(),
|
||||
'order_count' => (int)$collection->getOrders(true),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeAction(string $action): string
|
||||
{
|
||||
$action = trim($action);
|
||||
if (!in_array($action, [
|
||||
self::ACTION_CLEAN_CUSTOMER_RULES,
|
||||
self::ACTION_MERGE,
|
||||
self::ACTION_SPLIT_BY_MONTH,
|
||||
self::ACTION_RESET_HIDDEN_PRICES,
|
||||
self::ACTION_QUEUE_ECONOMIC,
|
||||
], true)) {
|
||||
throw new Exception('Invalid invoice collection bulk action.');
|
||||
}
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function normalizeInvoiceCollectionIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($ids as $id) {
|
||||
if (is_array($id) || is_object($id) || !is_numeric($id)) {
|
||||
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
||||
}
|
||||
$parsed = (int)$id;
|
||||
if ($parsed < 1 || $parsed > 999999999) {
|
||||
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
||||
}
|
||||
$normalized[$parsed] = $parsed;
|
||||
}
|
||||
$normalized = array_values($normalized);
|
||||
sort($normalized);
|
||||
if ($normalized === []) {
|
||||
throw new Exception('invoice_collection_ids must contain at least one id.');
|
||||
}
|
||||
if (count($normalized) > self::MAX_COLLECTIONS) {
|
||||
throw new Exception('Too many invoice collections selected.');
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeOptions(array $options): array
|
||||
{
|
||||
if (isset($options['target_invoice_collection_id'])) {
|
||||
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
|
||||
}
|
||||
ksort($options);
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
|
||||
{
|
||||
return hash('sha256', json_encode([
|
||||
'action' => $action,
|
||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'options' => $options,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private function confirmationPhrase(string $locale): string
|
||||
{
|
||||
$language = strtolower(substr(trim($locale), 0, 2));
|
||||
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
|
||||
}
|
||||
|
||||
private function previewId(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
private function previewCacheKey(string $previewId): string
|
||||
{
|
||||
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
|
||||
}
|
||||
|
||||
private function cachePreview(string $previewId, array $payload): void
|
||||
{
|
||||
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
|
||||
}
|
||||
|
||||
private function getCachedPreview(string $previewId): ?array
|
||||
{
|
||||
$raw = (new redis())->get($this->previewCacheKey($previewId));
|
||||
if (!$raw) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function deleteCachedPreview(string $previewId): void
|
||||
{
|
||||
(new redis())->delete($this->previewCacheKey($previewId));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class invoice_period_flag_service
|
||||
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
|
||||
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
|
||||
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
|
||||
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
private array $economicCustomerDiscountCache = [];
|
||||
private array $userDisplayNameCache = [];
|
||||
private array $orderItemsPreviewCache = [];
|
||||
@@ -30,6 +31,8 @@ class invoice_period_flag_service
|
||||
public function __construct()
|
||||
{
|
||||
invoice_period_flag_schema_bootstrap::ensureTables();
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
department_customer_price_overrides_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
public function createManualFlag(array $payload, int $userId): array
|
||||
@@ -688,6 +691,7 @@ class invoice_period_flag_service
|
||||
o.po AS order_po,
|
||||
o.notes AS order_notes,
|
||||
o.department_id,
|
||||
d.custom_pricing_only AS department_custom_pricing_only,
|
||||
o.reg_1,
|
||||
o.invoice_collection_id,
|
||||
o.wash_id,
|
||||
@@ -710,8 +714,21 @@ class invoice_period_flag_service
|
||||
p.max_quantity_per_order,
|
||||
c.name AS category_name,
|
||||
pdp.price AS department_price,
|
||||
product_discount.percentage AS product_discount_percentage,
|
||||
category_discount.percentage AS category_discount_percentage
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
|
||||
ELSE product_discount.percentage
|
||||
END AS product_discount_percentage,
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
|
||||
ELSE product_discount.fixed_price
|
||||
END AS product_fixed_price,
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN GREATEST(
|
||||
COALESCE(department_category_discount.percentage, 0),
|
||||
COALESCE(department_global_discount.percentage, 0)
|
||||
)
|
||||
ELSE category_discount.percentage
|
||||
END AS category_discount_percentage
|
||||
FROM orders o
|
||||
LEFT JOIN (
|
||||
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
|
||||
@@ -720,11 +737,12 @@ class invoice_period_flag_service
|
||||
GROUP BY customer_number
|
||||
) u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN departments d ON d.id = o.department_id
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
|
||||
FROM price_overrides po
|
||||
INNER JOIN users discount_user ON discount_user.id = po.user_id
|
||||
WHERE po.is_category = 0
|
||||
@@ -741,6 +759,32 @@ class invoice_period_flag_service
|
||||
) category_discount
|
||||
ON category_discount.customer_number = o.customer_id
|
||||
AND category_discount.product_or_category_id = p.category
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 0
|
||||
GROUP BY department_id, user_id, product_or_category_id
|
||||
) department_product_discount
|
||||
ON department_product_discount.department_id = o.department_id
|
||||
AND department_product_discount.user_id = u.id
|
||||
AND department_product_discount.product_or_category_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 1 AND product_or_category_id <> 'global'
|
||||
GROUP BY department_id, user_id, product_or_category_id
|
||||
) department_category_discount
|
||||
ON department_category_discount.department_id = o.department_id
|
||||
AND department_category_discount.user_id = u.id
|
||||
AND department_category_discount.product_or_category_id = p.category
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, MAX(percentage) AS percentage
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 1 AND product_or_category_id = 'global'
|
||||
GROUP BY department_id, user_id
|
||||
) department_global_discount
|
||||
ON department_global_discount.department_id = o.department_id
|
||||
AND department_global_discount.user_id = u.id
|
||||
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
|
||||
AND o.deleted_at IS NULL
|
||||
ORDER BY o.customer_id, o.id, oi.id";
|
||||
@@ -896,7 +940,7 @@ class invoice_period_flag_service
|
||||
}
|
||||
|
||||
$restrictedProducts = [
|
||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
|
||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
|
||||
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
|
||||
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
|
||||
];
|
||||
@@ -1921,7 +1965,19 @@ class invoice_period_flag_service
|
||||
|
||||
private function calculateExpectedPrice(array $row): int
|
||||
{
|
||||
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
if ($customMissingPrice) {
|
||||
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
}
|
||||
|
||||
$fixedPrice = $this->rowProductFixedPrice($row);
|
||||
if ($fixedPrice !== null) {
|
||||
return $fixedPrice;
|
||||
}
|
||||
|
||||
$base = $row['department_price'] !== null
|
||||
? (int)$row['department_price']
|
||||
: (int)($row['product_base_price'] ?? 0);
|
||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||
return (int)round($base * (1 - ($discount / 100)));
|
||||
}
|
||||
@@ -1929,13 +1985,18 @@ class invoice_period_flag_service
|
||||
private function priceBreakdown(array $row, int $expected): array
|
||||
{
|
||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||
$discount = $this->discountBreakdown($row);
|
||||
if ($customMissingPrice) {
|
||||
$discount['applied_discount_percentage'] = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'product_price' => (int)($row['product_base_price'] ?? 0),
|
||||
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_base_price' => $base,
|
||||
'product_fixed_price' => $this->rowProductFixedPrice($row),
|
||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||
'category_discount_percentage' => $discount['category_discount_percentage'],
|
||||
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
|
||||
@@ -1944,21 +2005,36 @@ class invoice_period_flag_service
|
||||
];
|
||||
}
|
||||
|
||||
private function isCustomMissingDepartmentPrice(array $row): bool
|
||||
{
|
||||
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
private function discountBreakdown(array $row): array
|
||||
{
|
||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
|
||||
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
|
||||
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
|
||||
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
|
||||
? 0
|
||||
: max($productDiscount, $categoryDiscount, $economicDiscount);
|
||||
|
||||
return [
|
||||
'product_discount_percentage' => $productDiscount,
|
||||
'category_discount_percentage' => $categoryDiscount,
|
||||
'economic_customer_discount_percentage' => $economicDiscount,
|
||||
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
|
||||
'applied_discount_percentage' => $appliedDiscount,
|
||||
];
|
||||
}
|
||||
|
||||
private function rowProductFixedPrice(array $row): ?int
|
||||
{
|
||||
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
|
||||
? (int)$row['product_fixed_price']
|
||||
: null;
|
||||
}
|
||||
|
||||
private function economicCustomerDiscountPercentage(array $row): int
|
||||
{
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
@@ -2022,6 +2098,11 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
|
||||
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
@@ -2036,8 +2117,7 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
|
||||
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
|
||||
|
||||
class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
{
|
||||
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
|
||||
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
|
||||
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
|
||||
private const PLATE_READER_REGIONS = 'dk,de,se,no';
|
||||
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
|
||||
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
|
||||
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
|
||||
private const RESULT_CACHE_TTL_SECONDS = 10;
|
||||
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
|
||||
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
|
||||
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
|
||||
/**
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private array $last_timings = [];
|
||||
|
||||
/**
|
||||
* The configuration of the module
|
||||
* @var licenseplaterecognizer_c
|
||||
@@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
* API URL
|
||||
* @var string
|
||||
*/
|
||||
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)';
|
||||
private string $api_url;
|
||||
|
||||
/**
|
||||
* @var array{enabled: bool, api_key: string}|null
|
||||
*/
|
||||
private ?array $runtime_config = null;
|
||||
|
||||
public function __construct()
|
||||
/**
|
||||
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
|
||||
*/
|
||||
private static ?array $runtime_config_cache = null;
|
||||
|
||||
public function __construct(bool $load_config = true, ?string $api_url = null)
|
||||
{
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
$this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
|
||||
|
||||
if ($load_config) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||
if (!$this->runtimeConfig()['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
}
|
||||
@@ -45,54 +74,516 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array
|
||||
{
|
||||
$image_processor = new image_processor();
|
||||
|
||||
//ADD PARAMETER IN REQUEST LIKE regions
|
||||
$data = array(
|
||||
'upload' => $base64_image,
|
||||
//'regions' => 'dk' // Optional
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayload($base64_image),
|
||||
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare new cURL resource
|
||||
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
),
|
||||
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
||||
);
|
||||
}
|
||||
|
||||
// Set HTTP Header for POST request
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
||||
"Authorization: Token " . $this->config->api_key->getVariableValue()
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromFile($image_path, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$response_data = json_decode($result, true);
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
return [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
'raw_response' => $response_data,
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
||||
{
|
||||
$started_at = microtime(true);
|
||||
$this->last_timings = [];
|
||||
$result_cache = null;
|
||||
$result_cache_key = null;
|
||||
|
||||
try {
|
||||
$config_started_at = microtime(true);
|
||||
$runtime_config = $this->runtimeConfig();
|
||||
if (!$runtime_config['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
$api_key = $runtime_config['api_key'];
|
||||
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
|
||||
|
||||
if ($result_cache_key_factory !== null) {
|
||||
$cache_started_at = microtime(true);
|
||||
try {
|
||||
$result_cache = $this->resultCacheStore();
|
||||
if ($result_cache !== null) {
|
||||
$result_cache_key = $result_cache_key_factory();
|
||||
if ($result_cache_key !== null) {
|
||||
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
||||
if ($cached_result !== null) {
|
||||
$this->last_timings['cache_hit'] = 1;
|
||||
return $cached_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->last_timings['cache_miss'] = 1;
|
||||
} finally {
|
||||
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
|
||||
}
|
||||
}
|
||||
|
||||
$payload_started_at = microtime(true);
|
||||
$data = $payload_factory();
|
||||
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
|
||||
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
if (!$ch instanceof \CurlHandle) {
|
||||
throw new Exception('Error initializing API request.');
|
||||
}
|
||||
|
||||
$curl_options = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
|
||||
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Token " . $api_key,
|
||||
'Expect:',
|
||||
],
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
if (defined('CURLOPT_TCP_NODELAY')) {
|
||||
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
|
||||
}
|
||||
curl_setopt_array($ch, $curl_options);
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$upstream_started_at = microtime(true);
|
||||
$result = curl_exec($ch);
|
||||
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
|
||||
$this->recordCurlTimings($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
|
||||
$parse_started_at = microtime(true);
|
||||
$response_data = json_decode($result, true);
|
||||
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
|
||||
$this->recordResponseTimings($response_data);
|
||||
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
$recognized_result = [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
];
|
||||
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
}
|
||||
|
||||
$recognized_result = [
|
||||
'success' => false,
|
||||
'message' => 'No license plate detected.',
|
||||
'raw_response' => $response_data,
|
||||
];
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
} finally {
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
}
|
||||
}
|
||||
|
||||
private static function clientDisconnectAbortCallback(): callable
|
||||
{
|
||||
return static function (): int {
|
||||
return connection_aborted() ? 1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
public function getLastTimings(): array
|
||||
{
|
||||
return $this->last_timings;
|
||||
}
|
||||
|
||||
private function elapsedMs(float $started_at): float
|
||||
{
|
||||
return (microtime(true) - $started_at) * 1000;
|
||||
}
|
||||
|
||||
private static function configuredApiUrl(): string
|
||||
{
|
||||
$configured = getenv('PLATE_RECOGNIZER_API_URL');
|
||||
if ($configured === false || trim((string)$configured) === '') {
|
||||
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return (string)$configured;
|
||||
}
|
||||
|
||||
private static function normalizeApiUrl(string $api_url): string
|
||||
{
|
||||
$api_url = trim($api_url);
|
||||
if ($api_url === '') {
|
||||
return self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return rtrim($api_url, '/');
|
||||
}
|
||||
|
||||
private function recordCurlTimings(\CurlHandle $curl_handle): void
|
||||
{
|
||||
$mapping = [
|
||||
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
|
||||
CURLINFO_CONNECT_TIME => 'upstream_connect',
|
||||
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
|
||||
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
|
||||
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
|
||||
CURLINFO_TOTAL_TIME => 'upstream_total',
|
||||
];
|
||||
|
||||
foreach ($mapping as $curl_info_option => $timing_key) {
|
||||
$value = curl_getinfo($curl_handle, $curl_info_option);
|
||||
if (!is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private function recordResponseTimings(mixed $response_data): void
|
||||
{
|
||||
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromUploadString(string $base64_image): string
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
|
||||
$image_data = base64_decode((string)$matches[1], true);
|
||||
if (is_string($image_data)) {
|
||||
return $this->buildResultCacheKeyFromBytes($image_data);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->buildResultCacheKeyFromBytes($base64_image);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromBytes(string $image_data): string
|
||||
{
|
||||
$context = hash_init('sha256');
|
||||
hash_update($context, $this->resultCacheContext());
|
||||
hash_update($context, "\0");
|
||||
hash_update($context, $image_data);
|
||||
|
||||
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
|
||||
}
|
||||
|
||||
private function resultCacheContext(): string
|
||||
{
|
||||
return self::RESULT_CACHE_CONTEXT;
|
||||
}
|
||||
|
||||
protected function resultCacheStore(): ?object
|
||||
{
|
||||
return $this->runtimeConfigCacheStore();
|
||||
}
|
||||
|
||||
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get($key);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayload(string $base64_image): array
|
||||
{
|
||||
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
|
||||
{
|
||||
return [
|
||||
'upload' => $upload,
|
||||
'config' => self::PLATE_READER_CONFIG_JSON,
|
||||
'regions' => self::PLATE_READER_REGIONS,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildUploadValue(string $base64_image): string|\CURLStringFile
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
|
||||
return $base64_image;
|
||||
}
|
||||
|
||||
$image_data = base64_decode((string)$matches[2], true);
|
||||
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
|
||||
return (string)$matches[2];
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
|
||||
}
|
||||
|
||||
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
|
||||
{
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if (!class_exists(\CURLStringFile::class)) {
|
||||
return $image_data;
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
|
||||
{
|
||||
$image_path = trim($image_path);
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
|
||||
throw new Exception('Image upload file is invalid.');
|
||||
}
|
||||
|
||||
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
|
||||
}
|
||||
|
||||
protected function runtimeConfig(): array
|
||||
{
|
||||
if ($this->runtime_config !== null) {
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
$cached_config = self::getSharedRuntimeConfigCache();
|
||||
if ($cached_config !== null) {
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
$cached_config = $this->readRuntimeConfigCacheStore();
|
||||
if ($cached_config !== null) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $cached_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
}
|
||||
|
||||
$values = $this->readRuntimeModuleConfig();
|
||||
|
||||
$this->runtime_config = [
|
||||
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
|
||||
'api_key' => (string)($values['api_key'] ?? ''),
|
||||
];
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $this->runtime_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->writeRuntimeConfigCacheStore($this->runtime_config);
|
||||
}
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
protected function shouldUseSharedRuntimeConfigCache(): bool
|
||||
{
|
||||
return static::class === self::class;
|
||||
}
|
||||
|
||||
private static function getSharedRuntimeConfigCache(): ?array
|
||||
{
|
||||
if (self::$runtime_config_cache === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
|
||||
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
|
||||
self::$runtime_config_cache = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$runtime_config_cache['values'];
|
||||
}
|
||||
|
||||
protected function runtimeConfigCacheStore(): ?object
|
||||
{
|
||||
return defined('redis') ? constant('redis') : null;
|
||||
}
|
||||
|
||||
private function readRuntimeConfigCacheStore(): ?array
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
|
||||
'api_key' => (string)$decoded['api_key'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{enabled: bool, api_key: string} $config
|
||||
*/
|
||||
private function writeRuntimeConfigCacheStore(array $config): void
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner config cache is best-effort; DB remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
private function parseModuleConfigBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return strtolower(trim((string)$value)) === 'true';
|
||||
}
|
||||
|
||||
protected function readRuntimeModuleConfig(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($db instanceof db) {
|
||||
$module = $db->escape_string('licenseplaterecognizer');
|
||||
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
|
||||
$values = [];
|
||||
|
||||
if ($result instanceof \mysqli_result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$variable = (string)($row['variable'] ?? '');
|
||||
if ($variable !== '') {
|
||||
$values[$variable] = (string)($row['value'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
if (!isset($this->config)) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => (string)$this->config->enabled->getVariableValue(),
|
||||
'api_key' => (string)$this->config->api_key->getVariableValue(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws Exception If the module is not enabled or if there is an error in the API request
|
||||
@@ -100,8 +591,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info
|
||||
{
|
||||
// Require the module to be enabled
|
||||
$this->requireModuleEnabled();
|
||||
$api_key = $this->runtimeConfig()['api_key'];
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, array(
|
||||
CURLOPT_URL => $this->api_url . '/info/',
|
||||
@@ -112,9 +603,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Token ' . $this->config->api_key->getVariableValue()
|
||||
),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Token ' . $api_key,
|
||||
],
|
||||
));
|
||||
$response = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
@@ -124,4 +615,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
}
|
||||
return new licenseplaterecognizer_info($response_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_exception extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly int $statusCode = 400,
|
||||
private readonly ?array $payload = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function payload(): array|string
|
||||
{
|
||||
return $this->payload ?? $this->getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`managed_group_id` INT NOT NULL,
|
||||
`role_key` VARCHAR(64) NOT NULL,
|
||||
`department_ids` LONGTEXT NOT NULL,
|
||||
`created_by_user_id` INT NOT NULL,
|
||||
`updated_by_user_id` INT NULL,
|
||||
`deactivated_at` DATETIME NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
|
||||
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
|
||||
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
|
||||
*/
|
||||
public function download(string $file): string
|
||||
{
|
||||
if ($this->shouldUseLocalTestStorage()) {
|
||||
return $this->getLocalTestObjectPath($file);
|
||||
}
|
||||
|
||||
$path = '/tmp/' . $file;
|
||||
$result = self::getS3Client()->getObject([
|
||||
self::getS3Client()->getObject([
|
||||
'Bucket' => self::getBucket(),
|
||||
'Key' => $file,
|
||||
'SaveAs' => $path
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for customer product price overrides.
|
||||
*/
|
||||
class price_overrides_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureColumns(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'price_overrides')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
|
||||
$db->query(
|
||||
"ALTER TABLE price_overrides
|
||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
||||
AFTER percentage"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$column = self::escapeIdentifier($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
}
|
||||
}
|
||||
@@ -1434,6 +1434,8 @@ class release_manager
|
||||
];
|
||||
}
|
||||
|
||||
$expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
|
||||
$enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
|
||||
$checked = [];
|
||||
try {
|
||||
foreach ($gateInput['api_ping_paths'] as $path) {
|
||||
@@ -1442,9 +1444,19 @@ class release_manager
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
throw new RuntimeException(sprintf('%s returned success=false.', $path));
|
||||
}
|
||||
$actualCommit = $this->releaseGateApiPayloadCommitSha($payload);
|
||||
if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'%s returned commit %s, expected %s.',
|
||||
$path,
|
||||
$actualCommit !== '' ? $actualCommit : 'unknown',
|
||||
$expectedCommit
|
||||
));
|
||||
}
|
||||
$checked[] = [
|
||||
'path' => $path,
|
||||
'status' => $json['status'],
|
||||
'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
|
||||
];
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
@@ -1471,10 +1483,24 @@ class release_manager
|
||||
'context' => [
|
||||
'api_base_url' => $apiBaseUrl,
|
||||
'checked' => $checked,
|
||||
'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function releaseGateApiPayloadCommitSha(array $payload): string
|
||||
{
|
||||
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
|
||||
foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) {
|
||||
$commit = self::normalizeCommitSha((string)($data[$key] ?? ''));
|
||||
if ($commit !== '') {
|
||||
return $commit;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function releaseGateFetchJson(string $baseUrl, string $path): array
|
||||
{
|
||||
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
|
||||
@@ -2066,11 +2092,7 @@ class release_manager
|
||||
|
||||
$eventId = (int)$event['id'];
|
||||
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => 'Automatic container update is already being processed for this commit.',
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
];
|
||||
return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2152,6 +2174,67 @@ class release_manager
|
||||
}
|
||||
}
|
||||
|
||||
private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
|
||||
{
|
||||
$timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
|
||||
$pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
|
||||
$deadline = time() + $timeout;
|
||||
$attempts = 0;
|
||||
$lastStatus = 'unknown';
|
||||
|
||||
do {
|
||||
$attempts++;
|
||||
$event = $this->releaseAutoSyncEventById($eventId);
|
||||
if ($event === null) {
|
||||
throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
|
||||
}
|
||||
|
||||
$lastStatus = (string)($event['status'] ?? 'unknown');
|
||||
if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
|
||||
$deployment = null;
|
||||
$deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
|
||||
if ($deploymentId !== null) {
|
||||
$deployment = $this->getDeployment($deploymentId);
|
||||
}
|
||||
$deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
|
||||
if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
|
||||
'deployment' => $this->publicDeployment($deployment),
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
'attempts' => $attempts,
|
||||
];
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'Automatic container update completed for event %d but the active %s deployment does not match %s.',
|
||||
$eventId,
|
||||
strtoupper($app),
|
||||
$commitSha
|
||||
));
|
||||
}
|
||||
|
||||
if ($lastStatus === 'failed') {
|
||||
$message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
|
||||
throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
|
||||
}
|
||||
|
||||
if (time() >= $deadline) {
|
||||
break;
|
||||
}
|
||||
|
||||
sleep($pollInterval);
|
||||
} while (true);
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
|
||||
$eventId,
|
||||
$timeout,
|
||||
$lastStatus
|
||||
));
|
||||
}
|
||||
|
||||
private function upsertReleaseAutoSyncEvent(array $input): array
|
||||
{
|
||||
$channelId = (int)$input['channel_id'];
|
||||
@@ -6232,6 +6315,7 @@ class release_manager
|
||||
}
|
||||
|
||||
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
||||
$previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
|
||||
return [
|
||||
'service_uuid' => $serviceUuid,
|
||||
'resource_type' => $resourceType,
|
||||
@@ -6241,9 +6325,76 @@ class release_manager
|
||||
'updated' => self::redactPayload($update ?? []),
|
||||
'runtime_env' => $runtimeEnvUpdate,
|
||||
'deployment' => self::redactPayload($deployment),
|
||||
'previous_applications' => self::redactPayload($previousApplications),
|
||||
];
|
||||
}
|
||||
|
||||
private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array
|
||||
{
|
||||
$stopped = [];
|
||||
foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) {
|
||||
try {
|
||||
$stopped[] = [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'stop_requested',
|
||||
'result' => $client->stopApplication($uuid),
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
$stopped[] = [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'warning',
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $stopped;
|
||||
}
|
||||
|
||||
private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ([
|
||||
'coolify_previous_application_uuid',
|
||||
'coolify_previous_artifact_app_uuid',
|
||||
'coolify_previous_artifact_application_uuid',
|
||||
'previous_application_uuid',
|
||||
'previous_app_uuid',
|
||||
] as $key) {
|
||||
if (is_scalar($context[$key] ?? null)) {
|
||||
$values[] = (string)$context[$key];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'coolify_previous_application_uuids',
|
||||
'coolify_previous_artifact_app_uuids',
|
||||
'previous_application_uuids',
|
||||
'previous_app_uuids',
|
||||
] as $key) {
|
||||
if (!is_array($context[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($context[$key] as $value) {
|
||||
if (is_scalar($value)) {
|
||||
$values[] = (string)$value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$activeUuid = trim($activeUuid);
|
||||
$uuids = [];
|
||||
foreach ($values as $value) {
|
||||
$uuid = trim((string)$value);
|
||||
if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) {
|
||||
continue;
|
||||
}
|
||||
$uuids[] = $uuid;
|
||||
}
|
||||
|
||||
return $uuids;
|
||||
}
|
||||
|
||||
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
|
||||
{
|
||||
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
|
||||
@@ -6252,6 +6403,7 @@ class release_manager
|
||||
}
|
||||
|
||||
if ($resourceType === 'application') {
|
||||
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
|
||||
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
||||
} else {
|
||||
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
||||
@@ -6264,12 +6416,45 @@ class release_manager
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
|
||||
{
|
||||
$keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
|
||||
if ($keys === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$key = trim((string)($row['key'] ?? $row['name'] ?? ''));
|
||||
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
|
||||
if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->deleteApplicationEnv($resourceUuid, $uuid);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
||||
{
|
||||
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
||||
$env = $contextEnv;
|
||||
$app = strtolower(trim((string)($target['app'] ?? '')));
|
||||
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
||||
|
||||
if ($app !== 'api') {
|
||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
||||
return $env;
|
||||
}
|
||||
|
||||
@@ -6291,21 +6476,37 @@ class release_manager
|
||||
$this->appendRuntimeEnvValue($env, $key, $value);
|
||||
}
|
||||
|
||||
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
||||
if ($deploymentCommitSha !== '') {
|
||||
foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) {
|
||||
if (!array_key_exists($key, $contextEnv)) {
|
||||
$env[$key] = $deploymentCommitSha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$env = array_replace($env, $contextEnv);
|
||||
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
||||
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
||||
}
|
||||
|
||||
private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
|
||||
{
|
||||
if ($deploymentCommitSha === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
|
||||
$env[$key] = $deploymentCommitSha;
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
|
||||
{
|
||||
$app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
|
||||
if ($app === 'frontend') {
|
||||
return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
|
||||
}
|
||||
if ($app === 'api') {
|
||||
return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function releaseCoolifyContextEnv(array $context): array
|
||||
{
|
||||
$env = [];
|
||||
@@ -7013,6 +7214,13 @@ class release_manager
|
||||
|
||||
private function releaseCoolifyGitCommitSha(array $target, array $context): string
|
||||
{
|
||||
foreach (['commit_sha', 'commit'] as $key) {
|
||||
$value = trim((string)($target[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'coolify_git_commit_sha',
|
||||
'git_commit_sha',
|
||||
@@ -8928,7 +9136,7 @@ class release_manager
|
||||
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
||||
return $payload;
|
||||
}
|
||||
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) {
|
||||
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
|
||||
if (is_array($payload[$key] ?? null)) {
|
||||
return $this->payloadRows($payload[$key]);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class router
|
||||
}
|
||||
// Regex
|
||||
$route = str_replace('/', '\/', $route);
|
||||
$route = preg_replace('/{[a-zA-Z0-9]+}/', '([a-zA-Z0-9]+)', $route);
|
||||
$route = preg_replace('/{[a-zA-Z0-9_]+}/', '([a-zA-Z0-9]+)', $route);
|
||||
if (preg_match('/^' . $route . '$/', $this->url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,9 @@ class selfserve_schema_bootstrap
|
||||
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)
|
||||
INDEX idx_selfserve_wash_sessions_created_at (created_at),
|
||||
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_order (order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
|
||||
@@ -95,7 +97,7 @@ class selfserve_schema_bootstrap
|
||||
session_id INT NOT NULL,
|
||||
task_id INT NULL,
|
||||
task_text VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
description TEXT NULL,
|
||||
services JSON NULL,
|
||||
buttons JSON NULL,
|
||||
dynamic_image_id INT NULL,
|
||||
@@ -197,6 +199,18 @@ class selfserve_schema_bootstrap
|
||||
'gate_ref_id',
|
||||
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
|
||||
);
|
||||
self::ensureColumnDataType(
|
||||
'department_selfserve_tasks',
|
||||
'description',
|
||||
['text', 'mediumtext', 'longtext'],
|
||||
'ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task'
|
||||
);
|
||||
self::ensureColumnDataType(
|
||||
'selfserve_wash_session_tasks',
|
||||
'description',
|
||||
['text', 'mediumtext', 'longtext'],
|
||||
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_session_tasks',
|
||||
'dynamic_images_vehicle_type',
|
||||
@@ -207,6 +221,16 @@ class selfserve_schema_bootstrap
|
||||
'wash_started_at',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_department_completed',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_order',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)'
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
@@ -239,4 +263,78 @@ class selfserve_schema_bootstrap
|
||||
}
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
public static function tableHasIndex(string $table, string $index): bool
|
||||
{
|
||||
global $db;
|
||||
$table = $db->escape_string($table);
|
||||
$index = $db->escape_string($index);
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
|
||||
$sql = "SELECT COUNT(*) AS c
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND INDEX_NAME = '$index'";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return false;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
|
||||
public static function ensureIndex(string $table, string $index, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
if (self::tableHasIndex($table, $index)) {
|
||||
return;
|
||||
}
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $acceptedDataTypes
|
||||
*/
|
||||
public static function ensureColumnDataType(string $table, string $column, array $acceptedDataTypes, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$columnInfo = self::columnInfo($table, $column);
|
||||
if ($columnInfo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dataType = strtolower((string)($columnInfo['DATA_TYPE'] ?? ''));
|
||||
$acceptedDataTypes = array_map(static fn(string $type): string => strtolower($type), $acceptedDataTypes);
|
||||
if (in_array($dataType, $acceptedDataTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function columnInfo(string $table, string $column): ?array
|
||||
{
|
||||
global $db;
|
||||
$table = $db->escape_string($table);
|
||||
$column = $db->escape_string($column);
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
|
||||
$sql = "SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND COLUMN_NAME = '$column'
|
||||
LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return null;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class shelly implements shelly_i
|
||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
||||
private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;
|
||||
private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;
|
||||
/**
|
||||
* @var array<int,array<string,mixed>>
|
||||
*/
|
||||
@@ -178,6 +180,9 @@ class shelly implements shelly_i
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
||||
// Execute the request
|
||||
$response = curl_exec($ch);
|
||||
// Get the status code
|
||||
@@ -224,6 +229,9 @@ class shelly implements shelly_i
|
||||
);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
|
||||
use interfaces\notification_i;
|
||||
use objects\departments_o;
|
||||
use objects\users_o;
|
||||
use slack\slack_c;
|
||||
use traits\notification_t;
|
||||
|
||||
require_once WD . '/modules/slack/slack_c.php';
|
||||
|
||||
class slack implements notification_i
|
||||
{
|
||||
use notification_t;
|
||||
|
||||
private ?slack_c $config = null;
|
||||
|
||||
public function getConfig(): slack_c
|
||||
{
|
||||
if ($this->config === null) {
|
||||
$this->config = new slack_c();
|
||||
}
|
||||
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
@@ -122,7 +134,7 @@ class slack implements notification_i
|
||||
. "Status: $status";
|
||||
}
|
||||
|
||||
public function send_message(string $string, string $module = null): void
|
||||
public function send_message(string $string, ?string $module = null): void
|
||||
{
|
||||
global $SLACK_DEFAULT_WEBHOOK;
|
||||
// Format the message if a module is provided
|
||||
@@ -132,4 +144,189 @@ class slack implements notification_i
|
||||
// Send the message to the slack webhook
|
||||
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
|
||||
}
|
||||
}
|
||||
|
||||
public function send_customer_registration_notification(int $customer_number): self
|
||||
{
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
self::add_log(self::send_webhook_message(
|
||||
$this->format_customer_registration($customer_number),
|
||||
$webhook
|
||||
));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sanitized customer-registration test notification to the saved Slack webhook.
|
||||
*
|
||||
* @return array{configured:bool,sent:bool,message:string}
|
||||
*/
|
||||
public function test_customer_registration_webhook(): array
|
||||
{
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return [
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack customer registration webhook URL is not configured.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->send_webhook_message(
|
||||
$this->format_customer_registration_test(),
|
||||
$webhook
|
||||
);
|
||||
$sent = $this->is_webhook_send_successful($result);
|
||||
|
||||
self::add_log($sent
|
||||
? 'Slack customer registration test webhook sent successfully.'
|
||||
: 'Slack customer registration test webhook failed.'
|
||||
);
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'sent' => $sent,
|
||||
'message' => $sent
|
||||
? 'Slack test message sent successfully.'
|
||||
: 'Slack test message failed.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sanitized internal department goal progress test notification to the saved Slack webhook.
|
||||
*
|
||||
* @return array{configured:bool,sent:bool,message:string}
|
||||
*/
|
||||
public function test_internal_department_goal_progress_webhook(): array
|
||||
{
|
||||
$webhook = $this->get_internal_department_goal_progress_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return [
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack internal department goal progress webhook URL is not configured.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->send_webhook_message(
|
||||
$this->format_internal_department_goal_progress_test(),
|
||||
$webhook
|
||||
);
|
||||
$sent = $this->is_webhook_send_successful($result);
|
||||
|
||||
self::add_log($sent
|
||||
? 'Slack internal department goal progress test webhook sent successfully.'
|
||||
: 'Slack internal department goal progress test webhook failed.'
|
||||
);
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'sent' => $sent,
|
||||
'message' => $sent
|
||||
? 'Slack test message sent successfully.'
|
||||
: 'Slack test message failed.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function get_customer_registration_webhook_url(): string
|
||||
{
|
||||
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
||||
}
|
||||
|
||||
public function get_internal_department_goal_progress_webhook_url(): string
|
||||
{
|
||||
return trim((string)$this->getConfig()->internal_department_goal_progress_webhook_url->getVariableValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function get_internal_department_ids(): array
|
||||
{
|
||||
return $this->getConfig()->internal_department_ids->getDepartmentIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $department_ids
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function set_internal_department_goal_progress_config(string $webhook_url, array $department_ids): array
|
||||
{
|
||||
$this->getConfig()->internal_department_goal_progress_webhook_url->setVariableValue(trim($webhook_url));
|
||||
$this->getConfig()->internal_department_ids->setVariableValue($department_ids);
|
||||
|
||||
return $this->get_internal_department_goal_progress_config();
|
||||
}
|
||||
|
||||
public function get_internal_department_goal_progress_config(): array
|
||||
{
|
||||
$departments = (new departments_o())->getFieldsWhere(
|
||||
[
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
],
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
'order_priority',
|
||||
]
|
||||
);
|
||||
|
||||
usort($departments, static function (array $a, array $b): int {
|
||||
return (int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)
|
||||
?: (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
|
||||
});
|
||||
|
||||
return [
|
||||
'internal_department_goal_progress_webhook_url' => $this->get_internal_department_goal_progress_webhook_url(),
|
||||
'internal_department_ids' => $this->get_internal_department_ids(),
|
||||
'departments' => array_map(static function (array $department): array {
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
];
|
||||
}, $departments),
|
||||
];
|
||||
}
|
||||
|
||||
public function is_webhook_send_successful(string $result): bool
|
||||
{
|
||||
return !str_starts_with($result, 'Failed to send message:');
|
||||
}
|
||||
|
||||
public function format_customer_registration(int $customer_number): string
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
$customerName = $customer->exists()
|
||||
? $customer->getCustomerName((int)$customer->customer_number->value())
|
||||
: '';
|
||||
$customerName = trim((string)$customerName);
|
||||
if ($customerName === '') {
|
||||
$customerName = 'Unknown customer';
|
||||
}
|
||||
|
||||
$safeCustomerNumber = (int)$customer_number;
|
||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
||||
|
||||
return "*New customer registered on Truck Wash*\n"
|
||||
. "Customer: $customerName ($safeCustomerNumber)\n"
|
||||
. "Open in Superuser: $customerUrl";
|
||||
}
|
||||
|
||||
public function format_customer_registration_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Customer registration notifications are configured correctly.";
|
||||
}
|
||||
|
||||
public function format_internal_department_goal_progress_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Internal department goal progress notifications are configured correctly.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
|
||||
class subuser_permission_templates_service
|
||||
{
|
||||
public const TEMPLATE_DEACTIVATED = 'deactivated';
|
||||
public const TEMPLATE_DRIVER = 'driver';
|
||||
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
|
||||
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
|
||||
public const TEMPLATE_CUSTOM = 'custom';
|
||||
|
||||
/**
|
||||
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
|
||||
*/
|
||||
private const TEMPLATES = [
|
||||
self::TEMPLATE_DEACTIVATED => [
|
||||
'label' => 'Deactivated',
|
||||
'description' => 'Keeps the driver linked to the customer without active access.',
|
||||
'enabled' => false,
|
||||
'permissions' => [],
|
||||
],
|
||||
self::TEMPLATE_DRIVER => [
|
||||
'label' => 'Driver',
|
||||
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_BOOKING_COORDINATOR => [
|
||||
'label' => 'Booking coordinator',
|
||||
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_FLEET_ADMIN => [
|
||||
'label' => 'Fleet admin',
|
||||
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'VEHICLES_EDIT',
|
||||
'VEHICLES_DELETE',
|
||||
'VEHICLES_ADD',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_EDIT',
|
||||
'SELFSERVE_DELETE',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
'ORDERS_EDIT',
|
||||
'SUBUSERS_LIST',
|
||||
'SUBUSERS_EDIT',
|
||||
'SUBUSERS_DELETE',
|
||||
'SUBUSERS_ADD',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{group:string,capability:string}>
|
||||
*/
|
||||
private const PERMISSION_CAPABILITIES = [
|
||||
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
|
||||
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
|
||||
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
|
||||
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
|
||||
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
|
||||
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
|
||||
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
|
||||
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
|
||||
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
|
||||
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
|
||||
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
|
||||
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
|
||||
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
|
||||
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
|
||||
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
|
||||
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
|
||||
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
|
||||
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const GROUP_ORDER = [
|
||||
'vehicles',
|
||||
'selfserve',
|
||||
'bookings',
|
||||
'orders',
|
||||
'driver_management',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function accessModel(): array
|
||||
{
|
||||
return [
|
||||
'templates' => $this->templates(),
|
||||
'groups' => $this->groups(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function templates(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
$templates[] = [
|
||||
'key' => $key,
|
||||
'label' => $template['label'],
|
||||
'description' => $template['description'],
|
||||
'enabled' => $template['enabled'],
|
||||
'permissions' => array_values($template['permissions']),
|
||||
'permission_groups' => $this->permissionGroups($template['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function groups(): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
$capabilities = [];
|
||||
foreach (self::PERMISSION_CAPABILITIES as $capability) {
|
||||
if ($capability['group'] === $group) {
|
||||
$capabilities[] = $capability['capability'];
|
||||
}
|
||||
}
|
||||
$groups[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($capabilities)),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}
|
||||
*/
|
||||
public function expandTemplate(string $templateKey): array
|
||||
{
|
||||
$key = $this->normalizeTemplateKey($templateKey);
|
||||
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
|
||||
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => self::TEMPLATES[$key]['enabled'],
|
||||
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeTemplateKey(?string $templateKey): ?string
|
||||
{
|
||||
if ($templateKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = strtolower(trim($templateKey));
|
||||
if ($key === self::TEMPLATE_CUSTOM) {
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
return array_key_exists($key, self::TEMPLATES) ? $key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
*/
|
||||
public function classify(array $permissions, bool $enabled = true): string
|
||||
{
|
||||
$normalized = $this->normalizePermissions($permissions);
|
||||
if (!$enabled || $normalized === []) {
|
||||
return self::TEMPLATE_DEACTIVATED;
|
||||
}
|
||||
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
if (!$template['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized === $this->normalizePermissions($template['permissions'])) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function permissionGroups(array $permissions): array
|
||||
{
|
||||
$permissions = $this->normalizePermissions($permissions);
|
||||
$groups = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
|
||||
if ($capability === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $capability['group'];
|
||||
$groups[$group] ??= [];
|
||||
$groups[$group][] = $capability['capability'];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
if (!isset($groups[$group])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($groups[$group])),
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizePermissions(array $permissions): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($permissions as $permission) {
|
||||
if ($permission instanceof subusers_permission_node_key) {
|
||||
$permission = $permission->name;
|
||||
}
|
||||
if (!is_string($permission)) {
|
||||
continue;
|
||||
}
|
||||
$permission = strtoupper(trim($permission));
|
||||
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
$normalized[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,8 @@ use Throwable;
|
||||
|
||||
class system_search_cache
|
||||
{
|
||||
public const PREFIX = 'system_search:v1:';
|
||||
public const PREFIX = 'system_search:v2:';
|
||||
public const QUERY_PREFIX = self::PREFIX . 'query:';
|
||||
public const INTENT_PREFIX = self::PREFIX . 'intent:';
|
||||
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
|
||||
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
|
||||
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
|
||||
@@ -40,24 +39,6 @@ class system_search_cache
|
||||
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
|
||||
}
|
||||
|
||||
public static function getIntent(string $hash): ?array
|
||||
{
|
||||
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
|
||||
{
|
||||
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
|
||||
}
|
||||
|
||||
public static function clearAll(): void
|
||||
{
|
||||
self::clearPattern(self::PREFIX . '*');
|
||||
@@ -68,11 +49,6 @@ class system_search_cache
|
||||
self::clearPattern(self::QUERY_PREFIX . '*');
|
||||
}
|
||||
|
||||
public static function clearIntentCaches(): void
|
||||
{
|
||||
self::clearPattern(self::INTENT_PREFIX . '*');
|
||||
}
|
||||
|
||||
public static function markDirtyTable(string $table): void
|
||||
{
|
||||
$table = trim($table, " `\t\n\r\0\x0B");
|
||||
|
||||
@@ -499,6 +499,7 @@ class system_search_document_index
|
||||
*/
|
||||
private function buildCustomerDiscountDocuments(): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
|
||||
$selectFields = [
|
||||
'po.id AS entity_id',
|
||||
@@ -506,6 +507,7 @@ class system_search_document_index
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
@@ -554,6 +556,7 @@ class system_search_document_index
|
||||
$row['search_text'] ?? null,
|
||||
$row['product_or_category_id'] ?? null,
|
||||
$row['percentage'] ?? null,
|
||||
$row['fixed_price'] ?? null,
|
||||
$row['user_id'] ?? null,
|
||||
]),
|
||||
$this->toIntOrNull($row['customer_number'] ?? null),
|
||||
@@ -564,6 +567,7 @@ class system_search_document_index
|
||||
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
|
||||
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
'is_category' => $row['is_category'] ?? null,
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use interfaces\system_search_intent_parser_i;
|
||||
use Throwable;
|
||||
|
||||
class system_search_openai_intent_parser implements system_search_intent_parser_i
|
||||
{
|
||||
private string $apiUrl = 'https://api.openai.com/v1/responses';
|
||||
private string $model = 'gpt-4.1-mini';
|
||||
private float $temperature = 0.1;
|
||||
private int $timeoutSeconds = 10;
|
||||
private int $maxAliases = 12;
|
||||
private int $maxEntityHints = 8;
|
||||
private int $maxAliasLength = 64;
|
||||
private int $maxHintLength = 32;
|
||||
private int $maxNormalizedQueryLength = 256;
|
||||
private int $maxFallbackReasonLength = 160;
|
||||
|
||||
/**
|
||||
* @var callable|null
|
||||
*/
|
||||
private $transport;
|
||||
private ?bool $forcedEnabled;
|
||||
private ?string $forcedApiKey;
|
||||
|
||||
public function __construct(?callable $transport = null, ?bool $forcedEnabled = null, ?string $forcedApiKey = null)
|
||||
{
|
||||
$this->transport = $transport;
|
||||
$this->forcedEnabled = $forcedEnabled;
|
||||
$this->forcedApiKey = $forcedApiKey;
|
||||
}
|
||||
|
||||
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return $this->failed('empty_query', 'none');
|
||||
}
|
||||
|
||||
[$enabled, $apiKey] = $this->resolveOpenAISettings();
|
||||
if (!$enabled) {
|
||||
return $this->failed('openai_disabled', 'none');
|
||||
}
|
||||
if (empty($apiKey)) {
|
||||
return $this->failed('openai_missing_key', 'none');
|
||||
}
|
||||
|
||||
$redactedQuery = self::redactSensitiveQuery($query);
|
||||
$payload = $this->buildPayload($redactedQuery, $allowedEntityTypes, $taxonomy);
|
||||
$cacheHash = md5(json_encode([
|
||||
'q' => $redactedQuery,
|
||||
'types' => $allowedEntityTypes,
|
||||
'taxonomy' => $taxonomy,
|
||||
'v' => 1,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$cached = system_search_cache::getIntent($cacheHash);
|
||||
if (is_array($cached) && isset($cached['success'])) {
|
||||
$cached['source'] = 'cache';
|
||||
return $this->normalizeResult($cached, $allowedEntityTypes);
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = $this->sendRequest($payload, $apiKey);
|
||||
$parsed = $this->parseResponse($raw);
|
||||
$parsed['source'] = 'openai';
|
||||
system_search_cache::setIntent($cacheHash, $parsed, 3600);
|
||||
return $this->normalizeResult($parsed, $allowedEntityTypes);
|
||||
} catch (Throwable $e) {
|
||||
return $this->failed($e->getMessage(), 'openai');
|
||||
}
|
||||
}
|
||||
|
||||
public static function redactSensitiveQuery(string $query): string
|
||||
{
|
||||
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
|
||||
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
|
||||
$query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query;
|
||||
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
|
||||
$query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query;
|
||||
$query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query;
|
||||
$query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query;
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function resolveOpenAISettings(): array
|
||||
{
|
||||
if ($this->forcedEnabled !== null) {
|
||||
return [(bool)$this->forcedEnabled, (string)($this->forcedApiKey ?? '')];
|
||||
}
|
||||
|
||||
try {
|
||||
$openai = new openai();
|
||||
$enabled = (bool)$openai->config->enabled->getVariableValue();
|
||||
$apiKey = (string)$openai->config->api_key->getVariableValue();
|
||||
return [$enabled, $apiKey];
|
||||
} catch (Throwable) {
|
||||
return [false, ''];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPayload(string $query, array $allowedEntityTypes, array $taxonomy): array
|
||||
{
|
||||
$taxonomyText = json_encode([
|
||||
'allowed_entity_types' => array_values($allowedEntityTypes),
|
||||
'taxonomy' => $taxonomy,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "You parse user search intent into strict JSON.\n"
|
||||
. "Rules:\n"
|
||||
. "- Keep output concise and valid JSON only.\n"
|
||||
. "- Do not invent entity types not listed in allowed_entity_types.\n"
|
||||
. "- Infer what the user is trying to find, not just literal words.\n"
|
||||
. "- aliases should contain user-friendly and backend-friendly equivalent terms.\n"
|
||||
. "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n"
|
||||
. "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n"
|
||||
. "- confidence must be between 0 and 1.\n"
|
||||
. "- association_hint should be true if related records likely needed.\n\n"
|
||||
. "Context:\n"
|
||||
. $taxonomyText . "\n\n"
|
||||
. "User query:\n"
|
||||
. $query;
|
||||
|
||||
return [
|
||||
'model' => $this->model,
|
||||
'temperature' => $this->temperature,
|
||||
'input' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
['type' => 'input_text', 'text' => $prompt],
|
||||
],
|
||||
],
|
||||
],
|
||||
'text' => [
|
||||
'format' => [
|
||||
'type' => 'json_schema',
|
||||
'name' => 'system_search_intent',
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'success' => ['type' => 'boolean'],
|
||||
'normalized_query' => [
|
||||
'type' => 'string',
|
||||
'maxLength' => $this->maxNormalizedQueryLength,
|
||||
],
|
||||
'aliases' => [
|
||||
'type' => 'array',
|
||||
'maxItems' => $this->maxAliases,
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
'maxLength' => $this->maxAliasLength,
|
||||
],
|
||||
],
|
||||
'entity_hints' => [
|
||||
'type' => 'array',
|
||||
'maxItems' => $this->maxEntityHints,
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
'maxLength' => $this->maxHintLength,
|
||||
],
|
||||
],
|
||||
'confidence' => [
|
||||
'type' => 'number',
|
||||
'minimum' => 0,
|
||||
'maximum' => 1,
|
||||
],
|
||||
'association_hint' => ['type' => 'boolean'],
|
||||
'fallback_reason' => [
|
||||
'anyOf' => [
|
||||
['type' => 'string', 'maxLength' => $this->maxFallbackReasonLength],
|
||||
['type' => 'null'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'required' => [
|
||||
'success',
|
||||
'normalized_query',
|
||||
'aliases',
|
||||
'entity_hints',
|
||||
'confidence',
|
||||
'association_hint',
|
||||
'fallback_reason',
|
||||
],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
'strict' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function sendRequest(array $payload, string $apiKey): array
|
||||
{
|
||||
if ($this->transport !== null) {
|
||||
$result = call_user_func($this->transport, $payload, $apiKey);
|
||||
if (!is_array($result)) {
|
||||
throw new Exception('Transport returned invalid payload');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
$curl = curl_init($this->apiUrl);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
]);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
$raw = curl_exec($curl);
|
||||
if ($raw === false) {
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
throw new Exception('cURL error: ' . $error);
|
||||
}
|
||||
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new Exception('Invalid JSON from OpenAI');
|
||||
}
|
||||
if ($status >= 400) {
|
||||
$message = $decoded['error']['message'] ?? ('OpenAI HTTP ' . $status);
|
||||
throw new Exception($message);
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function parseResponse(array $response): array
|
||||
{
|
||||
$text = $response['output'][0]['content'][0]['text'] ?? null;
|
||||
if (!is_string($text) || $text === '') {
|
||||
throw new Exception('Invalid response format (missing output text)');
|
||||
}
|
||||
$decoded = json_decode($text, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new Exception('Invalid intent JSON');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function normalizeResult(array $result, array $allowedEntityTypes = []): array
|
||||
{
|
||||
$normalizedQuery = trim((string)($result['normalized_query'] ?? ''));
|
||||
if (mb_strlen($normalizedQuery) > $this->maxNormalizedQueryLength) {
|
||||
$normalizedQuery = mb_substr($normalizedQuery, 0, $this->maxNormalizedQueryLength);
|
||||
}
|
||||
|
||||
$aliases = $this->sanitizeStringList((array)($result['aliases'] ?? []), $this->maxAliases, $this->maxAliasLength);
|
||||
$entityHints = $this->sanitizeStringList((array)($result['entity_hints'] ?? []), $this->maxEntityHints, $this->maxHintLength);
|
||||
if (!empty($allowedEntityTypes)) {
|
||||
$entityHints = array_values(array_intersect($allowedEntityTypes, $entityHints));
|
||||
}
|
||||
|
||||
$fallbackReason = null;
|
||||
if (isset($result['fallback_reason']) && $result['fallback_reason'] !== null) {
|
||||
$fallbackReason = trim((string)$result['fallback_reason']);
|
||||
if (mb_strlen($fallbackReason) > $this->maxFallbackReasonLength) {
|
||||
$fallbackReason = mb_substr($fallbackReason, 0, $this->maxFallbackReasonLength);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => (bool)($result['success'] ?? false),
|
||||
'normalized_query' => $normalizedQuery,
|
||||
'aliases' => $aliases,
|
||||
'entity_hints' => $entityHints,
|
||||
'confidence' => max(0.0, min(1.0, (float)($result['confidence'] ?? 0.0))),
|
||||
'association_hint' => (bool)($result['association_hint'] ?? false),
|
||||
'fallback_reason' => $fallbackReason,
|
||||
'source' => (string)($result['source'] ?? 'openai'),
|
||||
];
|
||||
}
|
||||
|
||||
private function sanitizeStringList(array $values, int $maxItems, int $maxLength): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($values as $value) {
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
$item = trim(mb_strtolower($value));
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
if (mb_strlen($item) > $maxLength) {
|
||||
$item = mb_substr($item, 0, $maxLength);
|
||||
}
|
||||
if (!in_array($item, $result, true)) {
|
||||
$result[] = $item;
|
||||
}
|
||||
if (count($result) >= $maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function failed(string $reason, string $source): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'normalized_query' => '',
|
||||
'aliases' => [],
|
||||
'entity_hints' => [],
|
||||
'confidence' => 0.0,
|
||||
'association_hint' => false,
|
||||
'fallback_reason' => $reason,
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
use interfaces\system_search_intent_parser_i;
|
||||
use Throwable;
|
||||
|
||||
class system_search_service
|
||||
{
|
||||
private system_search_intent_parser_i $intentParser;
|
||||
private int $lowConfidenceResultThreshold = 5;
|
||||
private int $lowConfidenceTopScoreThreshold = 60;
|
||||
private int $defaultEntityFetchLimit = 200;
|
||||
private int $defaultMaxResults = 50;
|
||||
private int $maxExpandedTerms = 24;
|
||||
private int $maxTermLength = 64;
|
||||
private int $recencyScoreTolerance = 12;
|
||||
private int $minimumEffectiveScore = 45;
|
||||
private int $minimumExplicitTypeScore = 20;
|
||||
private int $associationSeedScoreThreshold = 80;
|
||||
private array $rankingBoostByType = [
|
||||
'invoices' => 35,
|
||||
'orders' => 35,
|
||||
@@ -35,9 +35,8 @@ class system_search_service
|
||||
private array $tableColumnsCache = [];
|
||||
private array $customerContextCache = [];
|
||||
|
||||
public function __construct(?system_search_intent_parser_i $intentParser = null)
|
||||
public function __construct()
|
||||
{
|
||||
$this->intentParser = $intentParser ?? new system_search_openai_intent_parser();
|
||||
try {
|
||||
system_search_economic_customer_index::ensureTable();
|
||||
system_search_document_index::ensureTable();
|
||||
@@ -59,18 +58,13 @@ class system_search_service
|
||||
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
|
||||
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
|
||||
$includeAssociations = (bool)($options['include_associations'] ?? true);
|
||||
$debugIntent = (bool)($options['debug_intent'] ?? false);
|
||||
|
||||
$limit = (int)($options['limit'] ?? 50);
|
||||
$offset = (int)($options['offset'] ?? 0);
|
||||
if ($limit < 1) {
|
||||
$limit = 50;
|
||||
$maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
|
||||
if ($maxResults < 1) {
|
||||
$maxResults = $this->defaultMaxResults;
|
||||
}
|
||||
if ($limit > 200) {
|
||||
$limit = 200;
|
||||
}
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
if ($maxResults > $this->defaultMaxResults) {
|
||||
$maxResults = $this->defaultMaxResults;
|
||||
}
|
||||
|
||||
$allTypes = $this->allEntityTypes();
|
||||
@@ -79,11 +73,12 @@ class system_search_service
|
||||
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
|
||||
}
|
||||
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
|
||||
$terms = $this->buildExpandedTerms($this->tokenize($query));
|
||||
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
|
||||
|
||||
$baseMeta = [
|
||||
'query' => $query,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'max_results' => $maxResults,
|
||||
'allowed_types' => $activeTypes,
|
||||
'cache' => ['hit' => false],
|
||||
];
|
||||
@@ -94,7 +89,8 @@ class system_search_service
|
||||
'grouped_results' => $this->groupResultsByType([]),
|
||||
'meta' => [
|
||||
...$baseMeta,
|
||||
'total' => 0,
|
||||
'returned' => 0,
|
||||
'truncated' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -104,16 +100,14 @@ class system_search_service
|
||||
'include' => $includeTypes,
|
||||
'exclude' => $excludeTypes,
|
||||
'active' => $activeTypes,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'max' => $maxResults,
|
||||
'own' => $ownCustomerNumber,
|
||||
'own_only' => $ownOnlyTypes,
|
||||
'dept' => $allowedDepartmentIds,
|
||||
'assoc' => $includeAssociations,
|
||||
'dbg' => $debugIntent,
|
||||
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
||||
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
|
||||
'v' => 12,
|
||||
'v' => 13,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$cached = system_search_cache::getQuery($queryCacheHash);
|
||||
@@ -122,7 +116,6 @@ class system_search_service
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$terms = $this->buildExpandedTerms($this->tokenize($query));
|
||||
$entityBoost = [];
|
||||
$initialResults = $this->executeLexicalSearch(
|
||||
$activeTypes,
|
||||
@@ -135,76 +128,23 @@ class system_search_service
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds
|
||||
);
|
||||
|
||||
$intentAssociationHint = false;
|
||||
$intentMeta = [
|
||||
'invoked' => false,
|
||||
'source' => 'none',
|
||||
'status' => 'skipped',
|
||||
'confidence' => 0.0,
|
||||
'expanded_terms' => $terms,
|
||||
'entity_hints' => [],
|
||||
'fallback_reason' => null,
|
||||
];
|
||||
|
||||
$shouldInvokeIntent = !empty($terms) && (
|
||||
$this->shouldInvokeIntentParser($initialResults)
|
||||
|| $this->queryLooksIntentDriven($query, $terms)
|
||||
);
|
||||
if ($shouldInvokeIntent) {
|
||||
$intentMeta['invoked'] = true;
|
||||
$taxonomy = $this->taxonomy($activeTypes);
|
||||
$intent = $this->intentParser->parse($query, $activeTypes, $taxonomy);
|
||||
$intentMeta['source'] = (string)($intent['source'] ?? 'none');
|
||||
$intentMeta['confidence'] = (float)($intent['confidence'] ?? 0.0);
|
||||
$intentMeta['fallback_reason'] = $intent['fallback_reason'] ?? null;
|
||||
$intentMeta['entity_hints'] = (array)($intent['entity_hints'] ?? []);
|
||||
$intentAssociationHint = (bool)($intent['association_hint'] ?? false);
|
||||
|
||||
if (!empty($intent['success'])) {
|
||||
$intentMeta['status'] = 'ok';
|
||||
$boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? [])));
|
||||
foreach ($boostedTypes as $boostedType) {
|
||||
$entityBoost[$boostedType] = 25;
|
||||
}
|
||||
$expandedTerms = $this->buildExpandedTerms([
|
||||
...$terms,
|
||||
...$this->tokenize((string)($intent['normalized_query'] ?? '')),
|
||||
...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))),
|
||||
...$this->hintAliasTerms($boostedTypes, $taxonomy),
|
||||
]);
|
||||
$intentMeta['expanded_terms'] = $expandedTerms;
|
||||
|
||||
$initialResults = $this->executeLexicalSearch(
|
||||
$activeTypes,
|
||||
$expandedTerms,
|
||||
$entityBoost,
|
||||
$ownOnlyTypes,
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds
|
||||
);
|
||||
} else {
|
||||
$intentMeta['status'] = 'fallback';
|
||||
}
|
||||
}
|
||||
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
|
||||
|
||||
if ($includeAssociations) {
|
||||
$customerNumbers = [];
|
||||
foreach ($initialResults as $result) {
|
||||
if (!isset($result['customer_number'])) {
|
||||
$customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
|
||||
if ($customerNumber === null || $customerNumber <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
|
||||
if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
|
||||
continue;
|
||||
}
|
||||
$customerNumbers[] = (int)$result['customer_number'];
|
||||
$customerNumbers[] = $customerNumber;
|
||||
}
|
||||
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
|
||||
if (count($customerNumbers) > 15) {
|
||||
$customerNumbers = array_slice($customerNumbers, 0, 15);
|
||||
if (count($customerNumbers) > 5) {
|
||||
$customerNumbers = array_slice($customerNumbers, 0, 5);
|
||||
}
|
||||
if (!empty($customerNumbers)) {
|
||||
$associationTypes = array_values(array_intersect(
|
||||
@@ -232,18 +172,21 @@ class system_search_service
|
||||
}
|
||||
$item['score'] = max((int)$item['score'], 35);
|
||||
}
|
||||
unset($item);
|
||||
$associated = $this->filterRelevantResults($associated, $includeTypes);
|
||||
$initialResults = $this->mergeResults($initialResults, $associated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
|
||||
|
||||
$preferRecency = $this->shouldPreferRecencySort($query, $terms);
|
||||
usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
|
||||
$scoreA = (int)($a['score'] ?? 0);
|
||||
$scoreB = (int)($b['score'] ?? 0);
|
||||
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a);
|
||||
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b);
|
||||
$effectiveScoreA = $this->effectiveResultScore($a);
|
||||
$effectiveScoreB = $this->effectiveResultScore($b);
|
||||
$recencyA = $this->resultRecencyTimestamp($a);
|
||||
$recencyB = $this->resultRecencyTimestamp($b);
|
||||
$cancelledA = $this->isCancelledBookingResult($a);
|
||||
@@ -272,20 +215,17 @@ class system_search_service
|
||||
return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
|
||||
});
|
||||
|
||||
$total = count($initialResults);
|
||||
$paged = array_slice($initialResults, $offset, $limit);
|
||||
$grouped = $this->groupResultsByType($paged);
|
||||
$limited = array_slice($initialResults, 0, $maxResults);
|
||||
$grouped = $this->groupResultsByType($limited);
|
||||
|
||||
$meta = [
|
||||
...$baseMeta,
|
||||
'total' => $total,
|
||||
'returned' => count($limited),
|
||||
'truncated' => count($initialResults) > count($limited),
|
||||
];
|
||||
if ($debugIntent) {
|
||||
$meta['intent_parser'] = $intentMeta;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'results' => $paged,
|
||||
'results' => $limited,
|
||||
'grouped_results' => $grouped,
|
||||
'meta' => $meta,
|
||||
];
|
||||
@@ -294,13 +234,127 @@ class system_search_service
|
||||
return $payload;
|
||||
}
|
||||
|
||||
protected function shouldInvokeIntentParser(array $results): bool
|
||||
/**
|
||||
* @param array<int, string> $activeTypes
|
||||
* @param array<int, string> $includeTypes
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array
|
||||
{
|
||||
if (count($results) < $this->lowConfidenceResultThreshold) {
|
||||
return true;
|
||||
if (empty($activeTypes) || !empty($includeTypes)) {
|
||||
return $activeTypes;
|
||||
}
|
||||
$topScore = (int)($results[0]['score'] ?? 0);
|
||||
return $topScore < $this->lowConfidenceTopScoreThreshold;
|
||||
|
||||
$selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes()));
|
||||
foreach ($activeTypes as $entityType) {
|
||||
if (in_array($entityType, $selected, true)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) {
|
||||
$selected[] = $entityType;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_intersect($activeTypes, array_values(array_unique($selected))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultSearchEntityTypes(): array
|
||||
{
|
||||
return [
|
||||
'customers',
|
||||
'users',
|
||||
'employees',
|
||||
'orders',
|
||||
'order_bookings',
|
||||
'bookings',
|
||||
'bookings_new',
|
||||
'invoices',
|
||||
'vehicles',
|
||||
'departments',
|
||||
'products',
|
||||
'objects',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool
|
||||
{
|
||||
$normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' ';
|
||||
if (trim($normalizedQuery) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$aliases = system_search_registry::taxonomyAliases()[$entityType] ?? [];
|
||||
$human = str_replace('_', ' ', $entityType);
|
||||
$aliases[] = $entityType;
|
||||
$aliases[] = $human;
|
||||
$aliases[] = rtrim($human, 's');
|
||||
$aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== '')));
|
||||
|
||||
foreach ($aliases as $alias) {
|
||||
$aliasTerms = $this->tokenize($alias);
|
||||
if (empty($aliasTerms)) {
|
||||
continue;
|
||||
}
|
||||
if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) {
|
||||
return true;
|
||||
}
|
||||
if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
|
||||
return true;
|
||||
}
|
||||
$aliasText = trim(mb_strtolower($alias));
|
||||
if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $results
|
||||
* @param array<int, string> $includeTypes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterRelevantResults(array $results, array $includeTypes): array
|
||||
{
|
||||
$explicitTypes = array_flip($includeTypes);
|
||||
$filtered = [];
|
||||
foreach ($results as $result) {
|
||||
$entityType = (string)($result['entity_type'] ?? '');
|
||||
$score = (int)($result['score'] ?? 0);
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (isset($explicitTypes[$entityType])) {
|
||||
if ($score >= $this->minimumExplicitTypeScore) {
|
||||
$filtered[] = $result;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) {
|
||||
$filtered[] = $result;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool
|
||||
{
|
||||
$entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
|
||||
if (!in_array($entityType, ['customers', 'users'], true)) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,20 +401,6 @@ class system_search_service
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
if (empty($rows)) {
|
||||
$rows = $this->searchEntity(
|
||||
$entityType,
|
||||
$terms,
|
||||
$boost,
|
||||
$ownOnly,
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$rows = $this->searchEntity(
|
||||
$entityType,
|
||||
@@ -1086,6 +1126,7 @@ class system_search_service
|
||||
|
||||
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$customerFilter = '';
|
||||
if (!empty($forcedCustomerNumbers)) {
|
||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
|
||||
@@ -1100,11 +1141,12 @@ class system_search_service
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
@@ -1166,6 +1208,7 @@ class system_search_service
|
||||
'search_text',
|
||||
'product_or_category_id',
|
||||
'percentage',
|
||||
'fixed_price',
|
||||
'user_id',
|
||||
], $terms) + $entityBoost,
|
||||
'payload' => $this->augmentPayloadWithTemporal([
|
||||
@@ -1173,6 +1216,7 @@ class system_search_service
|
||||
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
|
||||
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
], $row),
|
||||
@@ -2643,6 +2687,11 @@ class system_search_service
|
||||
return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
|
||||
}
|
||||
|
||||
private function effectiveResultScore(array $result): int
|
||||
{
|
||||
return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result);
|
||||
}
|
||||
|
||||
private function boolishTrue(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
@@ -2702,7 +2751,7 @@ class system_search_service
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact intent.
|
||||
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches.
|
||||
if ($this->queryHasExplicitIdentifier($normalized)) {
|
||||
return false;
|
||||
}
|
||||
@@ -2725,68 +2774,6 @@ class system_search_service
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $boostedTypes
|
||||
* @param array<string, array<int, string>> $taxonomy
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function hintAliasTerms(array $boostedTypes, array $taxonomy): array
|
||||
{
|
||||
$terms = [];
|
||||
foreach ($boostedTypes as $type) {
|
||||
$aliases = $taxonomy[$type] ?? [];
|
||||
if (!is_array($aliases)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($aliases as $alias) {
|
||||
if (!is_string($alias) || trim($alias) === '') {
|
||||
continue;
|
||||
}
|
||||
$terms = [...$terms, ...$this->tokenize($alias)];
|
||||
if (count($terms) >= 12) {
|
||||
return array_slice(array_values(array_unique($terms)), 0, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_slice(array_values(array_unique($terms)), 0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect natural-language style queries where intent parsing is valuable
|
||||
* even when lexical score looks strong.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function queryLooksIntentDriven(string $query, array $terms): bool
|
||||
{
|
||||
$normalized = trim(mb_strtolower($query));
|
||||
if ($normalized === '' || count($terms) < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->queryHasExplicitIdentifier($normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1;
|
||||
$hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1;
|
||||
$hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1;
|
||||
|
||||
if ($hasStrongDomainLanguage && count($terms) >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($hasIntentVerb && count($terms) >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return mb_strlen($normalized) >= 28 && count($terms) >= 4;
|
||||
}
|
||||
|
||||
private function queryHasExplicitIdentifier(string $normalizedQuery): bool
|
||||
{
|
||||
if ($normalizedQuery === '') {
|
||||
@@ -2981,22 +2968,6 @@ class system_search_service
|
||||
return system_search_registry::allEntityTypes();
|
||||
}
|
||||
|
||||
private function taxonomy(array $activeTypes): array
|
||||
{
|
||||
$aliases = system_search_registry::taxonomyAliases();
|
||||
$taxonomy = [];
|
||||
foreach ($activeTypes as $type) {
|
||||
$resolved = $aliases[$type] ?? [];
|
||||
if (empty($resolved)) {
|
||||
$human = str_replace('_', ' ', $type);
|
||||
$singular = rtrim($human, 's');
|
||||
$resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== '')));
|
||||
}
|
||||
$taxonomy[$type] = $resolved;
|
||||
}
|
||||
return $taxonomy;
|
||||
}
|
||||
|
||||
private function groupResultsByType(array $results): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
@@ -291,6 +291,12 @@ class xlvask_automation_service
|
||||
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
|
||||
}
|
||||
|
||||
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
|
||||
{
|
||||
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
|
||||
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
|
||||
}
|
||||
|
||||
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
|
||||
{
|
||||
$usageBag = self::productBagForAutomation($usageItems);
|
||||
@@ -600,18 +606,52 @@ class xlvask_automation_service
|
||||
|
||||
if ($action === self::ACTION_ATTACH) {
|
||||
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
|
||||
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE;
|
||||
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
|
||||
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
|
||||
}
|
||||
|
||||
if ($action === self::ACTION_CREATE) {
|
||||
return $xlvask->config->automatic_order_creation_enabled->isTrue()
|
||||
&& $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS
|
||||
&& $confidence >= self::AUTO_CREATE_CONFIDENCE;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
|
||||
{
|
||||
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
|
||||
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
|
||||
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$usageItems = $context['items'] ?? [];
|
||||
$orderItems = $candidateOrder['order_items'] ?? [];
|
||||
return is_array($usageItems)
|
||||
&& is_array($orderItems)
|
||||
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
|
||||
}
|
||||
|
||||
private function candidateOrderFromSuggestion(array $suggestion): ?array
|
||||
{
|
||||
$candidateOrder = $suggestion['candidate_order'] ?? null;
|
||||
if (is_array($candidateOrder)) {
|
||||
return $candidateOrder;
|
||||
}
|
||||
|
||||
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
|
||||
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($candidateOrderJson, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
|
||||
{
|
||||
try {
|
||||
@@ -646,7 +686,7 @@ class xlvask_automation_service
|
||||
|
||||
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
|
||||
if ($automatic) {
|
||||
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.');
|
||||
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
|
||||
}
|
||||
|
||||
return $this->formatSuggestion($latest);
|
||||
@@ -660,6 +700,15 @@ class xlvask_automation_service
|
||||
}
|
||||
}
|
||||
|
||||
private function automaticFeedbackReason(array $suggestion, array $context): string
|
||||
{
|
||||
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
|
||||
return 'Automatisk accepteret: Prisoverensstemmelse.';
|
||||
}
|
||||
|
||||
return 'Automatisk accepteret.';
|
||||
}
|
||||
|
||||
private function createOrderFromContext(array $context): orders_o
|
||||
{
|
||||
$orderData = $context['proposed_order'];
|
||||
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
|
||||
{
|
||||
global $db;
|
||||
(new xlvask_usage_logs_o())->structure();
|
||||
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
|
||||
$where = [
|
||||
'FinishStatus = 1',
|
||||
'(ignored_at IS NULL OR ignored_at = "")',
|
||||
];
|
||||
|
||||
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
} else {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
}
|
||||
|
||||
if ($dateTo !== null && strtotime($dateTo) !== false) {
|
||||
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
}
|
||||
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"scripts": {
|
||||
"test": "composer test:unit",
|
||||
"analyse": "vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress",
|
||||
"static": "@analyse",
|
||||
"rector:dry-run": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --dry-run --config rector.php",
|
||||
"rector:fix": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --config rector.php",
|
||||
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
|
||||
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
||||
"test:api": [
|
||||
|
||||
@@ -39,8 +39,11 @@ $htaccess = "
|
||||
# php -- END cPanel-generated handler, do not edit
|
||||
";
|
||||
|
||||
// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.)
|
||||
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
||||
// Keep the compatibility rewrite file stable without rewriting it every minute.
|
||||
$htaccessPath = __DIR__ . '/.htaccess';
|
||||
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
|
||||
file_put_contents($htaccessPath, $htaccess);
|
||||
}
|
||||
|
||||
// Log the time of the cron job
|
||||
file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
|
||||
|
||||
@@ -8,6 +8,7 @@ use classes\invoice_period_flag_service;
|
||||
use classes\coolify_manager;
|
||||
use classes\replication_manager;
|
||||
use classes\redis;
|
||||
use classes\selfserve;
|
||||
use classes\system_search_cache;
|
||||
use classes\system_search_document_index;
|
||||
use classes\system_search_economic_customer_index;
|
||||
@@ -19,6 +20,8 @@ use classes\slack as Slack;
|
||||
use classes\email as Email;
|
||||
use classes\gatewayapi as GatewayAPI;
|
||||
use dynamicimages\images\machine_1;
|
||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
||||
use modules\selfserve\selfserve_c;
|
||||
use goals\classes\goals_criteria;
|
||||
use goals\services\goals_progress_alert_renderer;
|
||||
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
||||
@@ -33,9 +36,16 @@ use objects\users_o;
|
||||
use routes\moduleWeatherAPIRoute;
|
||||
|
||||
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||
|
||||
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
require_once __DIR__ . '/../classes/cron_schedule.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_definition.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_registry.php';
|
||||
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/cron_scheduler.php';
|
||||
|
||||
if (!defined('WD')) {
|
||||
exit;
|
||||
@@ -648,7 +658,6 @@ function SystemSearchCacheMaintenanceCron(): void
|
||||
|
||||
if ($rebuildRequest !== null) {
|
||||
system_search_cache::clearQueryCaches();
|
||||
system_search_cache::clearIntentCaches();
|
||||
$scope = (string)($rebuildRequest['scope'] ?? 'all');
|
||||
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
|
||||
if ($scope === 'types' && !empty($types)) {
|
||||
@@ -930,6 +939,7 @@ function buildDynamicImageCacheKey(array $variant): string
|
||||
'current_step' => (int)($variant['current_step'] ?? 0),
|
||||
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
||||
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
||||
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
|
||||
];
|
||||
|
||||
$json = json_encode($cacheParams);
|
||||
@@ -962,16 +972,11 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
||||
$image->current_step = max(0, $currentStep);
|
||||
$image->only_generate_current_step = $onlyCurrentStep;
|
||||
$image->setup();
|
||||
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
|
||||
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
|
||||
}
|
||||
|
||||
$dataUri = $image->exportAsBase64('png');
|
||||
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
|
||||
return null;
|
||||
}
|
||||
$imageData = base64_decode($matches[1], true);
|
||||
if ($imageData === false) {
|
||||
return null;
|
||||
}
|
||||
return $imageData;
|
||||
return $image->exportBinary('png');
|
||||
} catch (Throwable $e) {
|
||||
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
||||
return null;
|
||||
@@ -985,6 +990,19 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
||||
}
|
||||
}
|
||||
|
||||
function getSelfServeDynamicImageSizeModeForCron(): string
|
||||
{
|
||||
try {
|
||||
$mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
|
||||
} catch (Throwable) {
|
||||
return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
||||
}
|
||||
|
||||
return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
|
||||
? $mode
|
||||
: selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int|string>
|
||||
@@ -1457,17 +1475,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $cron_tasks as $task => $data ) {
|
||||
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
|
||||
$nextRun = $lastRun + $data['interval'];
|
||||
$cron_tasks[$task]['last_run'] = $lastRun;
|
||||
$cron_tasks[$task]['next_run'] = $nextRun;
|
||||
if ($nextRun <= time()) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
|
||||
$data['function']();
|
||||
redis->set_last_crond_run($task, time());
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n";
|
||||
} else {
|
||||
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
|
||||
function SelfserveOpeningRelayActivationCron(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
|
||||
return [
|
||||
'skipped' => true,
|
||||
'reason' => 'database_unavailable',
|
||||
];
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'));
|
||||
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
|
||||
$summary = [
|
||||
'checked_departments' => count($candidates),
|
||||
'activated_departments' => 0,
|
||||
'skipped_departments' => 0,
|
||||
'failed_departments' => 0,
|
||||
'activated_relays' => 0,
|
||||
'skipped_relays' => 0,
|
||||
'failed_relays' => 0,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$departmentId = (int)($candidate['department_id'] ?? 0);
|
||||
$opensAt = (string)($candidate['opens_at'] ?? '');
|
||||
if ($departmentId <= 0 || $opensAt === '') {
|
||||
$summary['skipped_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
|
||||
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
|
||||
$summary['skipped_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
|
||||
$summary['activated_relays'] += (int)$departmentSummary['activated'];
|
||||
$summary['skipped_relays'] += (int)$departmentSummary['skipped'];
|
||||
$summary['failed_relays'] += (int)$departmentSummary['failed'];
|
||||
|
||||
if ((int)$departmentSummary['failed'] > 0) {
|
||||
$summary['failed_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
|
||||
if ((int)$departmentSummary['activated'] > 0) {
|
||||
$summary['activated_departments']++;
|
||||
} else {
|
||||
$summary['skipped_departments']++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
|
||||
. $summary['activated_relays'] . " cleaner relays activated across "
|
||||
. $summary['activated_departments'] . " departments.\n";
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$weekday = strtolower($now->format('l'));
|
||||
$allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
if (!in_array($weekday, $allowedWeekdays, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$startColumn = $weekday . '_start';
|
||||
$endColumn = $weekday . '_end';
|
||||
$sql = "
|
||||
SELECT
|
||||
oh.department AS department_id,
|
||||
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
|
||||
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
|
||||
FROM department_time_bookings_opening_hours oh
|
||||
INNER JOIN department_variables dv ON dv.department_id = oh.department
|
||||
INNER JOIN department_lanes dl ON dl.department = oh.department
|
||||
WHERE dv.variable = 'selfserve_enabled'
|
||||
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
|
||||
AND oh.`$startColumn` IS NOT NULL
|
||||
AND oh.`$endColumn` IS NOT NULL
|
||||
AND dl.deleted_at IS NULL
|
||||
AND COALESCE(dl.selfserve_enabled, 1) = 1
|
||||
AND dl.relay_machine_cleaner_id IS NOT NULL
|
||||
AND TRIM(dl.relay_machine_cleaner_id) <> ''
|
||||
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$db->fetch_all($result),
|
||||
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
|
||||
$now,
|
||||
(string)($row['opens_at'] ?? ''),
|
||||
(string)($row['closes_at'] ?? '')
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayWindowActive(
|
||||
DateTimeImmutable $now,
|
||||
?string $opensAt,
|
||||
?string $closesAt
|
||||
): bool {
|
||||
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
|
||||
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
|
||||
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nowSeconds = ((int)$now->format('G') * 3600)
|
||||
+ ((int)$now->format('i') * 60)
|
||||
+ (int)$now->format('s');
|
||||
|
||||
if ($opensAtSeconds < $closesAtSeconds) {
|
||||
return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds;
|
||||
}
|
||||
|
||||
return $nowSeconds >= $opensAtSeconds;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
|
||||
{
|
||||
$time = trim((string)$time);
|
||||
if ($time === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hours = (int)$matches[1];
|
||||
$minutes = (int)$matches[2];
|
||||
$seconds = isset($matches[3]) ? (int)$matches[3] : 0;
|
||||
|
||||
if ($hours > 23 || $minutes > 59 || $seconds > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($hours * 3600) + ($minutes * 60) + $seconds;
|
||||
}
|
||||
|
||||
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array
|
||||
{
|
||||
$summary = [
|
||||
'activated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$selfserve = new selfserve();
|
||||
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
|
||||
|
||||
foreach ($lanes as $departmentLane) {
|
||||
if (!($departmentLane instanceof department_lanes_o)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$departmentLane->isSelfServeEnabled()) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$laneId = (int)$departmentLane->id;
|
||||
if ($laneId <= 0) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$lane = $selfserve->lane($laneId);
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$lane->setMachineCleanerRelayStatusHard(true);
|
||||
$summary['activated']++;
|
||||
} catch (Throwable $throwable) {
|
||||
$summary['failed']++;
|
||||
warn(
|
||||
'SelfserveOpeningRelayActivationCron failed for department '
|
||||
. $departmentId
|
||||
. ', lane '
|
||||
. $laneId
|
||||
. ': '
|
||||
. $throwable->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
|
||||
{
|
||||
if (
|
||||
$departmentLane === null
|
||||
|| !isset($departmentLane->relay_machine_cleaner_id)
|
||||
|| !is_object($departmentLane->relay_machine_cleaner_id)
|
||||
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationKey(
|
||||
int $departmentId,
|
||||
string $opensAt,
|
||||
DateTimeImmutable $now
|
||||
): string {
|
||||
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
|
||||
return 'selfserve:opening-cleaner-relays:'
|
||||
. $now->format('Y-m-d')
|
||||
. ':'
|
||||
. $departmentId
|
||||
. ':'
|
||||
. $normalizedOpensAt;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return redis->exists($cacheKey);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
redis->setEx($cacheKey, '1', 36 * 3600);
|
||||
} catch (Throwable) {
|
||||
// Redis idempotency should not block relay activation.
|
||||
}
|
||||
}
|
||||
|
||||
if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response_cron = (new \classes\cron_scheduler())->runDue('automatic');
|
||||
} catch (Throwable $throwable) {
|
||||
warn('Cron scheduler failed: ' . $throwable->getMessage());
|
||||
$response_cron = [
|
||||
'ran' => [],
|
||||
'count' => 0,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,10 +12,35 @@ interface licenseplaterecognizer_i extends universal_module_i
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from a PHP upload temp file without copying it into memory.
|
||||
* @param string $image_path The uploaded image temp-file path.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get usage information about the license plate recognizer module.
|
||||
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
||||
* @see licenseplaterecognizer_info
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace interfaces;
|
||||
|
||||
interface system_search_intent_parser_i
|
||||
{
|
||||
/**
|
||||
* Parse a natural-language search query into structured search hints.
|
||||
*
|
||||
* @param string $query The raw user query.
|
||||
* @param array $allowedEntityTypes Entity types the caller is allowed to search.
|
||||
* @param array $taxonomy Public taxonomy/aliases to improve intent parsing.
|
||||
* @return array{
|
||||
* success: bool,
|
||||
* normalized_query: string,
|
||||
* aliases: array<int, string>,
|
||||
* entity_hints: array<int, string>,
|
||||
* confidence: float,
|
||||
* association_hint: bool,
|
||||
* fallback_reason: string|null,
|
||||
* source: string
|
||||
* }
|
||||
*/
|
||||
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'backups.create_backup',
|
||||
'legacy_name' => 'backup',
|
||||
'name' => 'Create backup',
|
||||
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'backup',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 43200],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 60000,
|
||||
'priority' => 115,
|
||||
],
|
||||
];
|
||||
@@ -131,7 +131,7 @@
|
||||
"post": {
|
||||
"tags": ["User Bookings"],
|
||||
"summary": "Get download link for a booking's wash certificate",
|
||||
"description": "Requires permission `download_own_wash_certificate`. Returns a presigned download link if certificate exists and user has access.",
|
||||
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Returns a presigned download link if certificate exists and user has access.",
|
||||
"parameters": [ { "$ref": "#/components/parameters/id" } ],
|
||||
"responses": {
|
||||
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
||||
@@ -146,7 +146,7 @@
|
||||
"get": {
|
||||
"tags": ["User Bookings"],
|
||||
"summary": "Get download link for a booking's wash certificate PDF",
|
||||
"description": "Requires permission `download_own_wash_certificate`. Checks both legacy and current storage buckets.",
|
||||
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Checks both legacy and current storage buckets.",
|
||||
"parameters": [ { "$ref": "#/components/parameters/id" } ],
|
||||
"responses": {
|
||||
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_github_runner_backend_repository_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'github_runner_backend_repository',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'GitHub backend repository that receives Coolify-managed self-hosted runners.',
|
||||
'copenhagentruckwash/api',
|
||||
false,
|
||||
'copenhagentruckwash/api'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_github_runner_count_per_repo_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'github_runner_count_per_repo',
|
||||
'int',
|
||||
false,
|
||||
null,
|
||||
'Number of self-hosted GitHub runner containers to deploy per repository.',
|
||||
'1',
|
||||
false,
|
||||
'1'
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_github_runner_frontend_repository_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'github_runner_frontend_repository',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'GitHub frontend repository that receives Coolify-managed self-hosted runners.',
|
||||
'copenhagentruckwash/pleno-vue',
|
||||
false,
|
||||
'copenhagentruckwash/pleno-vue'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_github_runner_labels_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'github_runner_labels',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'Comma-separated GitHub Actions runner labels registered on each Coolify-managed runner.',
|
||||
'self-hosted,Linux,X64,default',
|
||||
false,
|
||||
'self-hosted,Linux,X64,default'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_github_runner_service_uuid_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'github_runner_service_uuid',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'Coolify service UUID for the managed GitHub self-hosted runner stack.',
|
||||
'abc123...',
|
||||
false,
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use classes\replication_secret_box;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_github_runner_token_c
|
||||
{
|
||||
use module_config_variable {
|
||||
setVariableValue as private traitSetVariableValue;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'github_runner_token',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'GitHub PAT used to register Coolify-managed self-hosted repository runners.',
|
||||
'github_pat_...',
|
||||
true,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
public function setVariableValue(mixed $value): void
|
||||
{
|
||||
$value = trim((string)($value ?? ''));
|
||||
if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) {
|
||||
$value = replication_secret_box::encrypt($value);
|
||||
}
|
||||
|
||||
$this->traitSetVariableValue($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'coolify.availability_monitor',
|
||||
'legacy_name' => 'CoolifyAvailabilityMonitorCron',
|
||||
'name' => 'Coolify availability monitor',
|
||||
'description' => 'Checks registered Coolify-managed infrastructure targets.',
|
||||
'module' => 'coolify',
|
||||
'handler' => 'CoolifyAvailabilityMonitorCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 2000,
|
||||
'priority' => 25,
|
||||
],
|
||||
[
|
||||
'id' => 'coolify.load_balancer_reconcile',
|
||||
'legacy_name' => 'CoolifyLoadBalancerReconcileCron',
|
||||
'name' => 'Coolify load balancer reconcile',
|
||||
'description' => 'Reconciles public gateway load balancer targets when automation is enabled.',
|
||||
'module' => 'coolify',
|
||||
'handler' => 'CoolifyLoadBalancerReconcileCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 26,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'dynamicimages.pre_render',
|
||||
'legacy_name' => 'PreRenderDynamicImagesCron',
|
||||
'name' => 'Pre-render dynamic images',
|
||||
'description' => 'Pre-renders common self-serve dynamic image variants into Redis.',
|
||||
'module' => 'dynamicimages',
|
||||
'handler' => 'PreRenderDynamicImagesCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 900],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 90,
|
||||
],
|
||||
];
|
||||
@@ -86,6 +86,14 @@ interface dynamicimages_image_i
|
||||
*/
|
||||
public function exportAsBase64(?string $format = null, int $quality = 90): string;
|
||||
|
||||
/**
|
||||
* Export the composed image as binary image data.
|
||||
* @param string|null $format Optional target format (e.g. 'png', 'jpeg')
|
||||
* @param int $quality Quality for lossy formats (0-100)
|
||||
* @return string binary image data
|
||||
*/
|
||||
public function exportBinary(?string $format = null, int $quality = 90): string;
|
||||
|
||||
/**
|
||||
* Directly serve the composed image to the client with proper headers.
|
||||
* Convenience wrapper for outputting binary image data.
|
||||
@@ -94,4 +102,4 @@ interface dynamicimages_image_i
|
||||
* @param int $quality Quality for lossy formats (0-100)
|
||||
*/
|
||||
public function servePicture(?string $format = null, int $quality = 90): void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,20 @@ trait dynamicimages_image_t
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resizeToMaxWidth(int $maxWidth): dynamicimages_image_i
|
||||
{
|
||||
$this->assertCanvasInitialized();
|
||||
if ($maxWidth <= 0) {
|
||||
throw new \InvalidArgumentException('Resize max width must be a positive integer.');
|
||||
}
|
||||
if ($this->canvasWidth === null || $this->canvasHeight === null || $this->canvasWidth <= $maxWidth) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$height = (int)round($this->canvasHeight * ($maxWidth / $this->canvasWidth));
|
||||
return $this->resize($maxWidth, max(1, $height));
|
||||
}
|
||||
|
||||
public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i
|
||||
{
|
||||
$this->assertCanvasInitialized();
|
||||
@@ -307,20 +321,8 @@ trait dynamicimages_image_t
|
||||
*/
|
||||
public function exportAsBase64(?string $format = null, int $quality = 90): string
|
||||
{
|
||||
// If a canvas is initialized, export that as PNG by default
|
||||
if ($this->image instanceof \Imagick) {
|
||||
$img = clone $this->image;
|
||||
$img->setImageFormat('png');
|
||||
// Quality influences compression for PNG differently; keep as hint
|
||||
if ($format !== null && strtolower($format) !== 'png') {
|
||||
// For now we only support PNG for composed images as requested
|
||||
}
|
||||
// Strip metadata to reduce size
|
||||
$img->stripImage();
|
||||
$blob = $img->getImageBlob();
|
||||
$img->clear();
|
||||
$img->destroy();
|
||||
return 'data:image/png;base64,' . base64_encode($blob);
|
||||
return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality));
|
||||
}
|
||||
|
||||
// Fallback: export first asset as-is
|
||||
@@ -341,6 +343,40 @@ trait dynamicimages_image_t
|
||||
return 'data:' . $mime . ';base64,' . base64_encode($data);
|
||||
}
|
||||
|
||||
public function exportBinary(?string $format = null, int $quality = 90): string
|
||||
{
|
||||
// If a canvas is initialized, export that as PNG by default
|
||||
if ($this->image instanceof \Imagick) {
|
||||
$img = clone $this->image;
|
||||
$img->setImageFormat('png');
|
||||
// Quality influences compression for PNG differently; keep as hint
|
||||
if ($format !== null && strtolower($format) !== 'png') {
|
||||
// For now we only support PNG for composed images as requested
|
||||
}
|
||||
// Strip metadata to reduce size
|
||||
$img->stripImage();
|
||||
$blob = $img->getImageBlob();
|
||||
$img->clear();
|
||||
$img->destroy();
|
||||
return $blob;
|
||||
}
|
||||
|
||||
// Fallback: export first asset as-is
|
||||
if (empty($this->assets)) {
|
||||
throw new \RuntimeException('No assets available to export.');
|
||||
}
|
||||
$asset = $this->assets[0];
|
||||
$path = $asset->getPath();
|
||||
if (!is_readable($path)) {
|
||||
throw new \RuntimeException('Asset is not readable: ' . $path);
|
||||
}
|
||||
$data = file_get_contents($path);
|
||||
if ($data === false) {
|
||||
throw new \RuntimeException('Failed to read asset: ' . $path);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getAsset(string $asset_name): ?dynamicimages_asset
|
||||
{
|
||||
foreach ($this->assets as $asset) {
|
||||
@@ -357,22 +393,18 @@ trait dynamicimages_image_t
|
||||
*/
|
||||
public function outputImage(?string $format = null, int $quality = 90): void
|
||||
{
|
||||
$dataUri = $this->exportAsBase64($format, $quality);
|
||||
// Extract mime type and base64 data
|
||||
if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) {
|
||||
$mimeType = $matches[1];
|
||||
$base64Data = $matches[2];
|
||||
// Decode base64 data
|
||||
$imageData = base64_decode($base64Data);
|
||||
if ($imageData !== false) {
|
||||
// Send appropriate headers
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . strlen($imageData));
|
||||
// Output the image data
|
||||
echo $imageData;
|
||||
exit;
|
||||
}
|
||||
$mimeType = 'image/png';
|
||||
if (!$this->image instanceof \Imagick && !empty($this->assets)) {
|
||||
$asset = $this->assets[0];
|
||||
$path = $asset->getPath();
|
||||
$imgInfo = is_readable($path) ? @getimagesize($path) : false;
|
||||
$mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream';
|
||||
}
|
||||
$imageData = $this->exportBinary($format, $quality);
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . strlen($imageData));
|
||||
echo $imageData;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,4 +414,4 @@ trait dynamicimages_image_t
|
||||
{
|
||||
$this->outputImage($format, $quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'economic.sync_user_customer_discounts',
|
||||
'legacy_name' => 'SyncUserEconomicCustomerDiscounts',
|
||||
'name' => 'Sync e-conomic customer discounts',
|
||||
'description' => 'Clears cached customer discounts so e-conomic discount data can refresh.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncUserEconomicCustomerDiscounts',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 180],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 2000,
|
||||
'priority' => 30,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.sync_user_customer_details',
|
||||
'legacy_name' => 'SyncUserEconomicCustomerDetails',
|
||||
'name' => 'Sync e-conomic customer details',
|
||||
'description' => 'Refreshes customer details and search documents from e-conomic.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncUserEconomicCustomerDetails',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 43200],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 110,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.sync_system_search_customer_index',
|
||||
'legacy_name' => 'SyncSystemSearchEconomicCustomerIndex',
|
||||
'name' => 'Sync e-conomic customer search index',
|
||||
'description' => 'Refreshes the system search e-conomic customer index.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncSystemSearchEconomicCustomerIndex',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 900],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 10000,
|
||||
'priority' => 75,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.sync_invoice_status',
|
||||
'legacy_name' => 'SyncEconomicInvoiceStatus',
|
||||
'name' => 'Sync e-conomic invoice status',
|
||||
'description' => 'Checks e-conomic invoice errors and draft status.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncEconomicInvoiceStatus',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 120],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 35,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.transfer_queue',
|
||||
'legacy_name' => 'EconomicTransferQueueCron',
|
||||
'name' => 'Process e-conomic transfer queue',
|
||||
'description' => 'Processes pending e-conomic transfer queue jobs in bounded batches.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'EconomicTransferQueueCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 30],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 20,
|
||||
],
|
||||
];
|
||||
@@ -14,6 +14,8 @@ class economic_customer_mo
|
||||
public null|string $message;
|
||||
public null|string $corporateIdentificationNumber;
|
||||
public null|string $email;
|
||||
public null|string $ean;
|
||||
public null|string $publicEntryNumber;
|
||||
public null|string $mobilePhone;
|
||||
public null|string $currency;
|
||||
public null|string $country;
|
||||
@@ -46,6 +48,8 @@ class economic_customer_mo
|
||||
$this->zip = ($customer->zip ?? null);
|
||||
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
|
||||
$this->email = ($customer->email ?? null);
|
||||
$this->ean = ($customer->ean ?? null);
|
||||
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
|
||||
$this->mobilePhone = ($customer->mobilePhone ?? null);
|
||||
$this->currency = ($customer->currency ?? null);
|
||||
$this->country = ($customer->country ?? null);
|
||||
@@ -100,6 +104,8 @@ class economic_customer_mo
|
||||
'zip' => $this->zip,
|
||||
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
|
||||
'email' => $this->email,
|
||||
'ean' => $this->ean,
|
||||
'publicEntryNumber' => $this->publicEntryNumber,
|
||||
'mobilePhone' => $this->mobilePhone,
|
||||
'currency' => $this->currency,
|
||||
'country' => $this->country,
|
||||
|
||||
+33
-5
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
||||
{
|
||||
$this->add_orders($invoiceDraftId, [$order], $currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add many orders to a draft invoice and flush their lines in batches.
|
||||
*
|
||||
* @param orders_o[] $orders
|
||||
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
|
||||
{
|
||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||
// Check if the order includes any items that should be included in the invoice
|
||||
if ($order->getIncludeInInvoiceCount() > 0) {
|
||||
$orders_with_invoice_lines = 0;
|
||||
|
||||
foreach ( $orders as $order ) {
|
||||
if (!$order instanceof orders_o) {
|
||||
throw new Exception('Order payload must contain orders_o instances');
|
||||
}
|
||||
// Check if the order includes any items that should be included in the invoice
|
||||
if ($order->getIncludeInInvoiceCount() <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orders_with_invoice_lines++;
|
||||
// Add the transaction header (Timestamp, department, etc.)
|
||||
$draftInvoice->addNewTransactionHeader($order);
|
||||
// Add the order lines
|
||||
$draftInvoice->addOrderItemLines($order);
|
||||
// Add an empty line, so the invoice is not empty
|
||||
$draftInvoice->addTextLine('');
|
||||
// Save the draft invoice lines
|
||||
$draftInvoice->addLines();
|
||||
}
|
||||
|
||||
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
|
||||
|
||||
return [
|
||||
'order_count' => count($orders),
|
||||
'orders_with_invoice_lines' => $orders_with_invoice_lines,
|
||||
...$metrics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
|
||||
$draft_invoice->addLines();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-10
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
|
||||
$customer_address = $customer->getAddress() ?? 'Ukendt';
|
||||
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
|
||||
$customer_city = $customer->getCity() ?? 'Ukendt';
|
||||
$recipient = [
|
||||
'name' => $customer_name,
|
||||
'address' => $customer_address,
|
||||
'zip' => $customer_zip,
|
||||
'city' => $customer_city,
|
||||
'vatZone' => [
|
||||
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
|
||||
],
|
||||
];
|
||||
$customer_ean = $customer->getEan();
|
||||
if ($customer_ean !== null) {
|
||||
$recipient['ean'] = $customer_ean;
|
||||
}
|
||||
$public_entry_number = $customer->getPublicEntryNumber();
|
||||
if ($public_entry_number !== null) {
|
||||
$recipient['publicEntryNumber'] = $public_entry_number;
|
||||
}
|
||||
|
||||
// Send the request
|
||||
$response = $this->send_request(
|
||||
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
|
||||
'currency' => $customer->getCurrency() ?? 'DKK',
|
||||
|
||||
// Set the recipient details
|
||||
'recipient' => [
|
||||
'name' => $customer_name,
|
||||
'address' => $customer_address,
|
||||
'zip' => $customer_zip,
|
||||
'city' => $customer_city,
|
||||
'vatZone' => [
|
||||
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
|
||||
],
|
||||
],
|
||||
'recipient' => $recipient,
|
||||
])
|
||||
);
|
||||
// Return the response as an object
|
||||
@@ -194,4 +203,4 @@ class economic_invoices_drafts_endpoint
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,29 @@ class economic_customer
|
||||
return $this->customer_data_object->email;
|
||||
}
|
||||
|
||||
public function getEan(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
return $this->nullableStringField('ean');
|
||||
}
|
||||
|
||||
public function getPublicEntryNumber(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
return $this->nullableStringField('publicEntryNumber');
|
||||
}
|
||||
|
||||
protected function nullableStringField(string $field): ?string
|
||||
{
|
||||
$value = $this->customer_data_object->{$field} ?? null;
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the customer address
|
||||
* @return string The customer address
|
||||
@@ -227,4 +250,4 @@ class economic_customer
|
||||
return $this->customer_data_object->vatZone->vatZoneNumber;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use objects\orders_o;
|
||||
|
||||
class economic_invoice_draft
|
||||
{
|
||||
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* The Economic draftInvoiceNumber
|
||||
* @var int $draft_invoice_number
|
||||
@@ -110,13 +112,55 @@ class economic_invoice_draft
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the lines to the draft invoice
|
||||
* @return void
|
||||
* Add the lines to the draft invoice.
|
||||
*/
|
||||
public function addLines(): void
|
||||
{
|
||||
$this->flushLinesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add queued draft lines using chunked requests.
|
||||
*
|
||||
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
*/
|
||||
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
|
||||
{
|
||||
$lines = array_values($this->draft_lines);
|
||||
$line_count = count($lines);
|
||||
if ($line_count === 0) {
|
||||
return [
|
||||
'line_count' => 0,
|
||||
'batch_count' => 0,
|
||||
'batch_sizes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$batch_size = max(1, $batch_size);
|
||||
$batch_sizes = [];
|
||||
foreach (array_chunk($lines, $batch_size) as $batch) {
|
||||
$this->sendDraftLines($batch);
|
||||
$batch_sizes[] = count($batch);
|
||||
}
|
||||
|
||||
$this->draft_lines = [];
|
||||
|
||||
return [
|
||||
'line_count' => $line_count,
|
||||
'batch_count' => count($batch_sizes),
|
||||
'batch_sizes' => $batch_sizes,
|
||||
];
|
||||
}
|
||||
|
||||
public function pendingLineCount(): int
|
||||
{
|
||||
return count($this->draft_lines);
|
||||
}
|
||||
|
||||
protected function sendDraftLines(array $draft_lines): object
|
||||
{
|
||||
$economic = new economic();
|
||||
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
|
||||
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+44
-3
@@ -6,6 +6,7 @@ use Exception;
|
||||
|
||||
class edge_gateway_agent_artifact_locator
|
||||
{
|
||||
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
|
||||
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
|
||||
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
||||
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
||||
@@ -30,6 +31,10 @@ class edge_gateway_agent_artifact_locator
|
||||
|
||||
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
||||
|
||||
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
|
||||
$candidateDirectories[] = $directory;
|
||||
}
|
||||
|
||||
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
||||
if ($mountedArtifactDirectory !== null) {
|
||||
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
|
||||
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
|
||||
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
||||
}
|
||||
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
|
||||
$candidateDirectories[] = $directory;
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
||||
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
|
||||
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function edgeAgentBuildDirectories(string $basePath): array
|
||||
{
|
||||
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||
$directories = [
|
||||
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
];
|
||||
|
||||
if (DIRECTORY_SEPARATOR === '/') {
|
||||
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_map(
|
||||
static fn(string $directory): string => self::normalizePath($directory),
|
||||
$directories
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function legacyPhpAgentDirectories(string $basePath): array
|
||||
{
|
||||
return [
|
||||
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function mountedArtifactDirectory(): ?string
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR !== '/') {
|
||||
|
||||
@@ -7,6 +7,7 @@ use Exception;
|
||||
class edge_gateway_install_service
|
||||
{
|
||||
private const ARTIFACTS = [
|
||||
'manifest.json' => 'application/json; charset=utf-8',
|
||||
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
@@ -26,7 +27,7 @@ class edge_gateway_install_service
|
||||
|
||||
public function buildInstallScript(string $plainToken): string
|
||||
{
|
||||
return $this->manager()->buildInstallScript($plainToken);
|
||||
return $this->normalizeLineEndings($this->manager()->buildInstallScript($plainToken));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,13 +43,17 @@ class edge_gateway_install_service
|
||||
*/
|
||||
public function readArtifact(string $fileName): string
|
||||
{
|
||||
if ($fileName === 'manifest.json') {
|
||||
return $this->buildManifest();
|
||||
}
|
||||
|
||||
$path = $this->artifactPath($fileName);
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
throw new Exception('Unable to read edge agent artifact');
|
||||
}
|
||||
|
||||
return $contents;
|
||||
return $this->normalizeLineEndings($contents);
|
||||
}
|
||||
|
||||
public function contentType(string $fileName): string
|
||||
@@ -66,15 +71,55 @@ class edge_gateway_install_service
|
||||
*/
|
||||
public function artifactPath(string $fileName): string
|
||||
{
|
||||
if (!array_key_exists($fileName, self::ARTIFACTS)) {
|
||||
if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
|
||||
throw new Exception('Unknown edge agent artifact');
|
||||
}
|
||||
|
||||
return edge_gateway_agent_artifact_locator::resolve($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildManifest(): string
|
||||
{
|
||||
$artifacts = [];
|
||||
foreach (self::ARTIFACTS as $fileName => $contentType) {
|
||||
if ($fileName === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $this->artifactPath($fileName);
|
||||
$sha256 = hash_file('sha256', $path);
|
||||
$bytes = filesize($path);
|
||||
if ($sha256 === false || $bytes === false) {
|
||||
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
|
||||
}
|
||||
|
||||
$artifacts[] = [
|
||||
'name' => $fileName,
|
||||
'sha256' => $sha256,
|
||||
'bytes' => $bytes,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'schema_version' => 1,
|
||||
'package' => 'truckwash-edge-agent',
|
||||
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
|
||||
'generated_at' => gmdate('c'),
|
||||
'artifacts' => $artifacts,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
}
|
||||
|
||||
private function normalizeLineEndings(string $contents): string
|
||||
{
|
||||
return str_replace(["\r\n", "\r"], "\n", $contents);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,7 @@ class edge_gateway_schema_bootstrap
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$pdo = self::pdo();
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateways (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -124,6 +123,31 @@ class edge_gateway_schema_bootstrap
|
||||
INDEX idx_edge_gateway_command_type (command_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_expected_relay_states (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
lane_id INT NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
relay_id VARCHAR(255) NOT NULL,
|
||||
device_id VARCHAR(255) NULL,
|
||||
local_ip VARCHAR(64) NULL,
|
||||
device_type VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN',
|
||||
expected_state TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL,
|
||||
completed_at DATETIME NULL,
|
||||
last_attempted_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
UNIQUE KEY uniq_edge_gateway_expected_relay_state (gateway_id, lane_id, role),
|
||||
INDEX idx_edge_gateway_expected_relay_gateway (gateway_id),
|
||||
INDEX idx_edge_gateway_expected_relay_department (department_id),
|
||||
INDEX idx_edge_gateway_expected_relay_pending (gateway_id, completed_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
@@ -233,11 +257,18 @@ class edge_gateway_schema_bootstrap
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
|
||||
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'device_type', "VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN' AFTER local_ip");
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'last_attempted_at', 'DATETIME NULL AFTER completed_at');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'last_error', 'TEXT NULL AFTER last_attempted_at');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'metadata_json', 'JSON NULL AFTER last_error');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'row_updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER row_updated_at');
|
||||
|
||||
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
|
||||
|
||||
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
|
||||
@@ -304,8 +335,6 @@ class edge_gateway_schema_bootstrap
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (self::tableHasColumn($table, $column)) {
|
||||
return;
|
||||
}
|
||||
@@ -314,7 +343,7 @@ class edge_gateway_schema_bootstrap
|
||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||
}
|
||||
|
||||
$db->query(
|
||||
self::pdo()->exec(
|
||||
"ALTER TABLE `$table`
|
||||
ADD COLUMN `$column` $definition"
|
||||
);
|
||||
@@ -327,8 +356,6 @@ class edge_gateway_schema_bootstrap
|
||||
string $definition,
|
||||
?string $afterColumn = null
|
||||
): void {
|
||||
global $db;
|
||||
|
||||
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
|
||||
return;
|
||||
}
|
||||
@@ -342,7 +369,7 @@ class edge_gateway_schema_bootstrap
|
||||
|
||||
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
|
||||
|
||||
$db->query(
|
||||
self::pdo()->exec(
|
||||
"ALTER TABLE `$table`
|
||||
CHANGE COLUMN `$from` `$to` $definition$positionClause"
|
||||
);
|
||||
@@ -350,38 +377,35 @@ class edge_gateway_schema_bootstrap
|
||||
|
||||
private 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());
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS c
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND COLUMN_NAME = '$column'"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
return false;
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
|
||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$statement = self::pdo()->prepare(
|
||||
"SELECT COUNT(*) AS c
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table
|
||||
AND COLUMN_NAME = :column"
|
||||
);
|
||||
$statement->execute([
|
||||
':table' => $table,
|
||||
':column' => $column,
|
||||
]);
|
||||
|
||||
$row = $statement->fetch();
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
|
||||
private static function syncOperationTypeColumns(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|
||||
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
$pdo = self::pdo();
|
||||
$pdo->exec(
|
||||
"UPDATE edge_gateway_operations
|
||||
SET type = operation_type
|
||||
WHERE operation_type IS NOT NULL
|
||||
@@ -389,7 +413,7 @@ class edge_gateway_schema_bootstrap
|
||||
AND (type IS NULL OR type = '' OR type <> operation_type)"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
$pdo->exec(
|
||||
"UPDATE edge_gateway_operations
|
||||
SET operation_type = type
|
||||
WHERE type IS NOT NULL
|
||||
@@ -397,4 +421,88 @@ class edge_gateway_schema_bootstrap
|
||||
AND (operation_type IS NULL OR operation_type = '')"
|
||||
);
|
||||
}
|
||||
|
||||
private static function pdo(): \PDO
|
||||
{
|
||||
$envConfig = self::databaseConfigFromEnvironment();
|
||||
if ($envConfig !== null) {
|
||||
return self::connectPdo($envConfig);
|
||||
}
|
||||
|
||||
if (!class_exists(db::class, false)) {
|
||||
$dbClassPath = __DIR__ . '/../../../classes/db.php';
|
||||
if (is_file($dbClassPath)) {
|
||||
require_once $dbClassPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists(db::class)) {
|
||||
throw new \RuntimeException('Database connection helper is not available.');
|
||||
}
|
||||
|
||||
return db::getPDO();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{host:string,user:string,password:string,database:string,port:int}|null
|
||||
*/
|
||||
private static function databaseConfigFromEnvironment(): ?array
|
||||
{
|
||||
$target = strtolower(self::envString('CONFIG_DB_TARGET') ?: 'live');
|
||||
if ($target !== 'debug') {
|
||||
$target = 'live';
|
||||
}
|
||||
|
||||
$host = self::databaseEnvValue('HOST', $target);
|
||||
$user = self::databaseEnvValue('USER', $target);
|
||||
$database = self::databaseEnvValue('DATABASE', $target);
|
||||
if ($host === '' || $user === '' || $database === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => $user,
|
||||
'password' => self::databaseEnvValue('PASSWORD', $target),
|
||||
'database' => $database,
|
||||
'port' => (int)(self::databaseEnvValue('PORT', $target) ?: 3306),
|
||||
];
|
||||
}
|
||||
|
||||
private static function databaseEnvValue(string $key, string $target): string
|
||||
{
|
||||
$liveValue = self::envString('CONFIG_DB_' . $key);
|
||||
$debugValue = self::envString('CONFIG_DB_DEBUG_' . $key);
|
||||
|
||||
if ($target === 'debug' && $debugValue !== '') {
|
||||
return $debugValue;
|
||||
}
|
||||
|
||||
return $liveValue;
|
||||
}
|
||||
|
||||
private static function envString(string $key): string
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value === false || $value === null) {
|
||||
$value = $_ENV[$key] ?? $_SERVER[$key] ?? '';
|
||||
}
|
||||
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{host:string,user:string,password:string,database:string,port:int} $config
|
||||
*/
|
||||
private static function connectPdo(array $config): \PDO
|
||||
{
|
||||
$port = $config['port'] > 0 ? $config['port'] : 3306;
|
||||
$dsn = "mysql:host={$config['host']};port={$port};dbname={$config['database']};charset=utf8mb4";
|
||||
|
||||
return new \PDO($dsn, $config['user'], $config['password'], [
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
||||
\PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
|
||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
|
||||
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
||||
@@ -98,6 +99,8 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
|
||||
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
||||
$this->post('/edge-agent/gateways/{id}/expected-relay-states', fn() => $this->handleAgentExpectedRelayStates());
|
||||
$this->post('/edge-agent/gateways/{id}/relay-state-results', fn() => $this->handleAgentRelayStateResults());
|
||||
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
||||
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
|
||||
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
|
||||
@@ -109,6 +112,7 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/selfserve/machine-signal', fn() => $this->handleBrokerSelfserveMachineSignal());
|
||||
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
||||
@@ -398,7 +402,11 @@ class edgeGatewaysRoute
|
||||
$response->error('Missing token', 400);
|
||||
}
|
||||
|
||||
$response->success($this->install()->verifyInstallToken($token));
|
||||
try {
|
||||
$response->success($this->install()->verifyInstallToken($token));
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleAgentInstallTokenStatus(): void
|
||||
@@ -407,17 +415,21 @@ class edgeGatewaysRoute
|
||||
self::requireParameters(['token', 'status']);
|
||||
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->registry()->reportInstallTokenStatus(
|
||||
(string)$payload['token'],
|
||||
[
|
||||
'status' => (string)$payload['status'],
|
||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
||||
]
|
||||
));
|
||||
try {
|
||||
$response->success($this->registry()->reportInstallTokenStatus(
|
||||
(string)$payload['token'],
|
||||
[
|
||||
'status' => (string)$payload['status'],
|
||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
||||
]
|
||||
));
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function renderArtifact(string $fileName): void
|
||||
@@ -439,15 +451,19 @@ class edgeGatewaysRoute
|
||||
self::requireParameters(['token']);
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
$response->success(
|
||||
$this->registry()->claimGateway(
|
||||
(string)$payload['token'],
|
||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
),
|
||||
201
|
||||
);
|
||||
try {
|
||||
$response->success(
|
||||
$this->registry()->claimGateway(
|
||||
(string)$payload['token'],
|
||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
),
|
||||
201
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleAgentHeartbeat(): void
|
||||
@@ -555,6 +571,31 @@ class edgeGatewaysRoute
|
||||
));
|
||||
}
|
||||
|
||||
private function handleAgentExpectedRelayStates(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->buildExpectedRelayStatesForAgent(
|
||||
$gatewayId,
|
||||
$this->requireAgentToken($payload),
|
||||
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : 0
|
||||
));
|
||||
}
|
||||
|
||||
private function handleAgentRelayStateResults(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$results = isset($payload['results']) && is_array($payload['results']) ? (array)$payload['results'] : [];
|
||||
$response->success($this->manager()->recordExpectedRelayStateResults(
|
||||
$gatewayId,
|
||||
$this->requireAgentToken($payload),
|
||||
$results
|
||||
));
|
||||
}
|
||||
|
||||
private function handleAgentPresence(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
@@ -706,6 +747,21 @@ class edgeGatewaysRoute
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerSelfserveMachineSignal(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
try {
|
||||
$result = (new selfserve_machine_signal())->recordBrokerEdgeGatewaySignal($gatewayId, $payload);
|
||||
$response->success($result, !empty($result['recorded']) ? 201 : 202);
|
||||
} catch (\Throwable $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBrokerBrowserStreamValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace email\templates;
|
||||
|
||||
use email\helpers\email_template;use objects\users_o;
|
||||
use email\helpers\email_template;
|
||||
use objects\users_o;
|
||||
|
||||
class email_template_new_customer
|
||||
{
|
||||
@@ -52,6 +53,7 @@ class email_template_new_customer
|
||||
*/
|
||||
public function generate_html(): string
|
||||
{
|
||||
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
|
||||
ob_start();
|
||||
# Start of the html
|
||||
?>
|
||||
@@ -73,7 +75,7 @@ class email_template_new_customer
|
||||
|
||||
<!-- Intro -->
|
||||
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
|
||||
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash.
|
||||
Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
|
||||
</p>
|
||||
|
||||
<!-- You can now wash your trucks -->
|
||||
@@ -185,4 +187,19 @@ class email_template_new_customer
|
||||
# End of the html
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
private function getCustomerRegistrationLabel(): string
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
|
||||
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
|
||||
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
|
||||
|
||||
$customer->getCustomerEcocomicData($this->customer_number);
|
||||
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
|
||||
if ($corporate_identification_number !== '') {
|
||||
$customer_label .= ' (' . $corporate_identification_number . ')';
|
||||
}
|
||||
|
||||
return $customer_label;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'failover.replica_monitor',
|
||||
'legacy_name' => 'ReplicaFailoverMonitorCron',
|
||||
'name' => 'Replica failover monitor',
|
||||
'description' => 'Checks replicated services and promotes eligible replicas during automatic failover.',
|
||||
'module' => 'failover',
|
||||
'handler' => 'ReplicaFailoverMonitorCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 24,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'goals.progress_alerts',
|
||||
'legacy_name' => 'GoalsProgressAlertsCron',
|
||||
'name' => 'Goals progress alerts',
|
||||
'description' => 'Evaluates department goal alert schedules and sends due progress alerts.',
|
||||
'module' => 'goals',
|
||||
'handler' => 'GoalsProgressAlertsCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 600,
|
||||
'estimated_duration_ms' => 10000,
|
||||
'priority' => 45,
|
||||
],
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user