Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10814e68ae | ||
|
|
fefe18a719 | ||
|
|
9b2d5d5291 | ||
|
|
e1fb79d9b6 | ||
|
|
879dfcf79a | ||
|
|
0feb705059 | ||
|
|
69b3bf83c4 | ||
|
|
5bac316e4b | ||
|
|
233133365d | ||
|
|
026492c3bd | ||
|
|
403f93e62c | ||
|
|
582edd3e6c | ||
|
|
a4fafaf7fb | ||
|
|
327e9cf817 | ||
|
|
012e5366ba | ||
|
|
fa1ade555f | ||
|
|
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 |
@@ -0,0 +1,49 @@
|
|||||||
|
# Default branch protection
|
||||||
|
|
||||||
|
`master` is changed through pull requests. Do not push or publish directly to
|
||||||
|
the default branch, including through automation or the Git Data API.
|
||||||
|
|
||||||
|
## Normal publishing flow
|
||||||
|
|
||||||
|
1. Create a scoped `agent/*` or feature branch from the current `origin/master`.
|
||||||
|
2. Commit and push only the intended changes.
|
||||||
|
3. Open a pull request targeting `master`.
|
||||||
|
4. Wait for the `Required CI` check. If `master` moves, update the branch and
|
||||||
|
wait for the strict check to rerun.
|
||||||
|
5. Resolve every review conversation and squash-merge the pull request.
|
||||||
|
6. Confirm the post-merge `Release Manager gate` completes on `master`.
|
||||||
|
|
||||||
|
The aggregate check covers the PHP unit, integration, API, and legacy matrix,
|
||||||
|
plus Edge Agent, Edge Broker, and Edge Gateway Backend. Qodana is advisory and
|
||||||
|
the Release Manager gate is intentionally post-merge.
|
||||||
|
|
||||||
|
## Desired ruleset
|
||||||
|
|
||||||
|
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json)
|
||||||
|
is the importable final desired-state repository-ruleset request body. For the
|
||||||
|
initial POST, copy the file and override `enforcement` to `disabled`. Inspect
|
||||||
|
the normalized ruleset and verify a green preparation PR and post-merge run,
|
||||||
|
then PUT the exact committed file to activate it.
|
||||||
|
|
||||||
|
The desired rule targets `~DEFAULT_BRANCH`, requires pull requests with zero
|
||||||
|
approvals, conversation resolution, strict `Required CI` from GitHub Actions
|
||||||
|
integration `15368`, squash-only linear history, and blocks deletion and force
|
||||||
|
pushes. Repository administrators receive pull-request-only bypass; they do not
|
||||||
|
receive a standing direct-push bypass.
|
||||||
|
|
||||||
|
When the ruleset is activated, align repository settings at the same time:
|
||||||
|
retain squash merging, disable merge commits and rebase merging, enable
|
||||||
|
auto-merge and branch-update suggestions, delete merged branches automatically,
|
||||||
|
keep the Actions token read-only, and prevent Actions from approving reviews.
|
||||||
|
|
||||||
|
## Break glass
|
||||||
|
|
||||||
|
When an incident cannot wait for the normal gate:
|
||||||
|
|
||||||
|
1. Open a pull request and describe the incident, risk, and reason for bypass.
|
||||||
|
2. Have a repository administrator use the pull-request-only bypass.
|
||||||
|
3. Monitor `Required CI` and the post-merge Release Manager workflow.
|
||||||
|
4. Open a follow-up pull request for any deferred validation or remediation.
|
||||||
|
|
||||||
|
Never bypass by updating `refs/heads/master` directly. Ruleset changes and
|
||||||
|
emergency bypasses must remain visible in GitHub's audit trail.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "Protect default branch",
|
||||||
|
"target": "branch",
|
||||||
|
"enforcement": "active",
|
||||||
|
"bypass_actors": [
|
||||||
|
{
|
||||||
|
"actor_id": 5,
|
||||||
|
"actor_type": "RepositoryRole",
|
||||||
|
"bypass_mode": "pull_request"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": {
|
||||||
|
"ref_name": {
|
||||||
|
"exclude": [],
|
||||||
|
"include": [
|
||||||
|
"~DEFAULT_BRANCH"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"type": "deletion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "non_fast_forward"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "required_linear_history"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "pull_request",
|
||||||
|
"parameters": {
|
||||||
|
"allowed_merge_methods": [
|
||||||
|
"squash"
|
||||||
|
],
|
||||||
|
"dismiss_stale_reviews_on_push": false,
|
||||||
|
"require_code_owner_review": false,
|
||||||
|
"require_last_push_approval": false,
|
||||||
|
"required_approving_review_count": 0,
|
||||||
|
"required_review_thread_resolution": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "required_status_checks",
|
||||||
|
"parameters": {
|
||||||
|
"do_not_enforce_on_create": false,
|
||||||
|
"required_status_checks": [
|
||||||
|
{
|
||||||
|
"context": "Required CI",
|
||||||
|
"integration_id": 15368
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strict_required_status_checks_policy": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: # Specify your branches here
|
branches:
|
||||||
- main # The 'main' branch
|
- master
|
||||||
- 'releases/*' # The release branches
|
- beta
|
||||||
|
- canary
|
||||||
|
- internal
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
qodana:
|
qodana:
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ name: Tests
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- beta
|
||||||
|
- canary
|
||||||
|
- internal
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
php:
|
php:
|
||||||
@@ -336,11 +348,42 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||||
|
|
||||||
|
required-ci:
|
||||||
|
name: Required CI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||||
|
if: ${{ always() }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Verify required jobs succeeded
|
||||||
|
env:
|
||||||
|
PHP_RESULT: ${{ needs.php.result }}
|
||||||
|
EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
|
||||||
|
EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
|
||||||
|
EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
failed=0
|
||||||
|
for dependency in \
|
||||||
|
"php=${PHP_RESULT}" \
|
||||||
|
"edge-agent=${EDGE_AGENT_RESULT}" \
|
||||||
|
"edge-broker=${EDGE_BROKER_RESULT}" \
|
||||||
|
"edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
|
||||||
|
do
|
||||||
|
name="${dependency%%=*}"
|
||||||
|
result="${dependency#*=}"
|
||||||
|
if [ "$result" != "success" ]; then
|
||||||
|
echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
test "$failed" -eq 0
|
||||||
|
|
||||||
release-manager-gate:
|
release-manager-gate:
|
||||||
name: Release Manager gate
|
name: Release Manager gate
|
||||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
needs: [required-ci]
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Record Release Manager API gate
|
- name: Record Release Manager API gate
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Backend API for Copenhagen Truck Wash services.
|
Backend API for Copenhagen Truck Wash services.
|
||||||
|
|
||||||
|
Changes are published from a scoped feature branch through a pull request to
|
||||||
|
`master`; direct default-branch pushes are not part of the release workflow.
|
||||||
|
See [default branch protection](.github/BRANCH_PROTECTION.md) for the CI gate
|
||||||
|
and emergency procedure.
|
||||||
|
|
||||||
## Architecture & Stack
|
## Architecture & Stack
|
||||||
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
|
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
|
||||||
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
|
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
|
||||||
|
|||||||
+1339
-92
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
|
|||||||
*/
|
*/
|
||||||
public function isValidFilePath(string $filePath): bool
|
public function isValidFilePath(string $filePath): bool
|
||||||
{
|
{
|
||||||
// Check if the file path is valid
|
if (
|
||||||
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
|
$filePath === ''
|
||||||
|
|| str_starts_with($filePath, '/')
|
||||||
|
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode('/', $filePath) as $segment) {
|
||||||
|
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
|
|||||||
{
|
{
|
||||||
$host = 'https://api.truckwash.io';
|
$host = 'https://api.truckwash.io';
|
||||||
|
|
||||||
|
$this->requireValidFilePath($fileName);
|
||||||
|
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
|
||||||
|
|
||||||
// Generate a direct download URL for the given file name
|
// Generate a direct download URL for the given file name
|
||||||
return $host . '/files/' . $fileName;
|
return $host . '/files/' . $encodedPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class backup_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 backup_records (
|
||||||
|
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(191) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||||
|
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
|
||||||
|
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
|
||||||
|
storage_prefix VARCHAR(255) NOT NULL,
|
||||||
|
manifest_key VARCHAR(255) NULL,
|
||||||
|
manifest_sha256 CHAR(64) NULL,
|
||||||
|
encryption_key_id VARCHAR(191) NULL,
|
||||||
|
component_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
requested_by_user_id INT NULL,
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
verified_at DATETIME NULL,
|
||||||
|
expires_at DATETIME 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_backup_records_status_created (status, created_at),
|
||||||
|
KEY idx_backup_records_verified (verified_at),
|
||||||
|
KEY idx_backup_records_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS backup_components (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
backup_uuid VARCHAR(64) NOT NULL,
|
||||||
|
component_type VARCHAR(32) NOT NULL,
|
||||||
|
logical_name VARCHAR(191) NOT NULL,
|
||||||
|
source_bucket VARCHAR(191) NULL,
|
||||||
|
source_prefix VARCHAR(255) NULL,
|
||||||
|
storage_key VARCHAR(255) NULL,
|
||||||
|
manifest_key VARCHAR(255) NULL,
|
||||||
|
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
content_sha256 CHAR(64) NULL,
|
||||||
|
encrypted_sha256 CHAR(64) NULL,
|
||||||
|
encryption_key_id VARCHAR(191) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
error_message TEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_backup_components_backup (backup_uuid),
|
||||||
|
KEY idx_backup_components_status (status),
|
||||||
|
KEY idx_backup_components_type_name (component_type, logical_name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
job_type VARCHAR(32) NOT NULL,
|
||||||
|
backup_uuid VARCHAR(64) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||||
|
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
progress_message VARCHAR(255) NULL,
|
||||||
|
payload_json LONGTEXT NULL,
|
||||||
|
result_json LONGTEXT NULL,
|
||||||
|
actor_user_id INT NULL,
|
||||||
|
locked_at DATETIME NULL,
|
||||||
|
lock_owner VARCHAR(191) NULL,
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_backup_jobs_status_created (status, created_at),
|
||||||
|
KEY idx_backup_jobs_backup (backup_uuid),
|
||||||
|
KEY idx_backup_jobs_type_status (job_type, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
restore_job_id BIGINT UNSIGNED NULL,
|
||||||
|
preview_job_id BIGINT UNSIGNED NULL,
|
||||||
|
backup_uuid VARCHAR(64) NOT NULL,
|
||||||
|
actor_user_id INT NULL,
|
||||||
|
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
|
||||||
|
confirmation_fingerprint CHAR(64) NULL,
|
||||||
|
reason TEXT NULL,
|
||||||
|
ip_address VARCHAR(64) NULL,
|
||||||
|
user_agent VARCHAR(255) NULL,
|
||||||
|
pre_restore_backup_uuid VARCHAR(64) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_backup_restore_audit_backup (backup_uuid),
|
||||||
|
KEY idx_backup_restore_audit_job (restore_job_id),
|
||||||
|
KEY idx_backup_restore_audit_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,11 @@ class coolify_api_client
|
|||||||
return $this->request('GET', '/services');
|
return $this->request('GET', '/services');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listApplications(): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/applications');
|
||||||
|
}
|
||||||
|
|
||||||
public function listGithubApps(): array
|
public function listGithubApps(): array
|
||||||
{
|
{
|
||||||
return $this->request('GET', '/github-apps');
|
return $this->request('GET', '/github-apps');
|
||||||
@@ -169,6 +174,11 @@ class coolify_api_client
|
|||||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function stopService(string $uuid): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
|
||||||
|
}
|
||||||
|
|
||||||
public function stopApplication(string $uuid): array
|
public function stopApplication(string $uuid): array
|
||||||
{
|
{
|
||||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
||||||
@@ -179,6 +189,11 @@ class coolify_api_client
|
|||||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteApplication(string $uuid): array
|
||||||
|
{
|
||||||
|
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
|
||||||
|
}
|
||||||
|
|
||||||
public function listDeployments(): array
|
public function listDeployments(): array
|
||||||
{
|
{
|
||||||
return $this->request('GET', '/deployments');
|
return $this->request('GET', '/deployments');
|
||||||
|
|||||||
@@ -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,681 @@
|
|||||||
|
<?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 queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): 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->taskIsLocked($state)) {
|
||||||
|
throw new RuntimeException('Cron task is already running.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->fetchOne(
|
||||||
|
"SELECT * FROM cron_task_runs
|
||||||
|
WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued'
|
||||||
|
ORDER BY id DESC LIMIT 1"
|
||||||
|
);
|
||||||
|
if ($existing !== null) {
|
||||||
|
$this->markTaskQueued($definition);
|
||||||
|
return $this->publicRun($existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduled_for = date('Y-m-d H:i:s');
|
||||||
|
$this->query(
|
||||||
|
"INSERT INTO cron_task_runs
|
||||||
|
(task_id, module, source, status, actor_user_id, scheduled_for, force_run)
|
||||||
|
VALUES ("
|
||||||
|
. $this->sql($definition->id) . ', '
|
||||||
|
. $this->sql($definition->module) . ", 'manual', 'queued', "
|
||||||
|
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||||
|
. $this->sql($scheduled_for) . ', '
|
||||||
|
. ($force ? '1' : '0')
|
||||||
|
. ")"
|
||||||
|
);
|
||||||
|
|
||||||
|
$run_id = (int)$this->insertId();
|
||||||
|
$this->markTaskQueued($definition);
|
||||||
|
|
||||||
|
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runDue(string $source = 'automatic'): array
|
||||||
|
{
|
||||||
|
$this->ensureReady();
|
||||||
|
$this->syncDefinitions();
|
||||||
|
|
||||||
|
$ran = [];
|
||||||
|
foreach ($this->queuedRuns() as $queuedRun) {
|
||||||
|
try {
|
||||||
|
$run = $this->runQueuedRun($queuedRun);
|
||||||
|
if ($run !== null) {
|
||||||
|
$ran[] = $run;
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$ran[] = [
|
||||||
|
'task_id' => (string)($queuedRun['task_id'] ?? ''),
|
||||||
|
'module' => (string)($queuedRun['module'] ?? ''),
|
||||||
|
'source' => (string)($queuedRun['source'] ?? 'manual'),
|
||||||
|
'status' => 'skipped',
|
||||||
|
'error_message' => $throwable->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 markExpiredRunningRuns(): int
|
||||||
|
{
|
||||||
|
$this->ensureReady();
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$message = 'Task lock expired before completion.';
|
||||||
|
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_runs r
|
||||||
|
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
|
||||||
|
SET r.status = 'timed_out',
|
||||||
|
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
|
||||||
|
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
|
||||||
|
s.current_run_id = NULL,
|
||||||
|
s.locked_until = NULL,
|
||||||
|
s.lock_owner = NULL,
|
||||||
|
s.last_status = 'timed_out',
|
||||||
|
s.last_error = " . $this->sql($message) . "
|
||||||
|
WHERE r.status = 'running'
|
||||||
|
AND s.locked_until IS NOT NULL
|
||||||
|
AND s.locked_until < " . $this->sql($now)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->affectedRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
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, $force);
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function executeClaimedRun(cron_task_definition $definition, int $run_id, float $started): array
|
||||||
|
{
|
||||||
|
$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);
|
||||||
|
|
||||||
|
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 taskIsLocked(array $state): bool
|
||||||
|
{
|
||||||
|
$lockedUntil = (string)($state['locked_until'] ?? '');
|
||||||
|
return $lockedUntil !== ''
|
||||||
|
&& strtotime($lockedUntil) !== false
|
||||||
|
&& strtotime($lockedUntil) >= time();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markTaskQueued(cron_task_definition $definition): void
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_state
|
||||||
|
SET last_status = 'queued',
|
||||||
|
last_error = NULL,
|
||||||
|
next_run_at = CASE
|
||||||
|
WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . "
|
||||||
|
ELSE next_run_at
|
||||||
|
END
|
||||||
|
WHERE task_id = " . $this->sql($definition->id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function queuedRuns(): array
|
||||||
|
{
|
||||||
|
return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runQueuedRun(array $queuedRun): ?array
|
||||||
|
{
|
||||||
|
$run_id = (int)($queuedRun['id'] ?? 0);
|
||||||
|
$definition = $this->registry->get((string)($queuedRun['task_id'] ?? ''));
|
||||||
|
if ($run_id < 1 || $definition === null) {
|
||||||
|
if ($run_id > 0) {
|
||||||
|
$this->skipQueuedRun($run_id, 'Cron task not found.');
|
||||||
|
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$force = (bool)($queuedRun['force_run'] ?? false);
|
||||||
|
$state = $this->stateRows()[$definition->id] ?? [];
|
||||||
|
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||||
|
if (!$enabled && !$force) {
|
||||||
|
$this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition);
|
||||||
|
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->claimLock($definition)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$started = microtime(true);
|
||||||
|
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_runs
|
||||||
|
SET status = 'running',
|
||||||
|
started_at = " . $this->sql($started_at) . ",
|
||||||
|
lock_owner = " . $this->sql($this->lock_owner) . "
|
||||||
|
WHERE id = $run_id AND status = 'queued'"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->affectedRows() !== 1) {
|
||||||
|
$this->clearClaimedLock($definition);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
|
||||||
|
{
|
||||||
|
$completed_at = date('Y-m-d H:i:s');
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_runs
|
||||||
|
SET status = 'skipped',
|
||||||
|
completed_at = " . $this->sql($completed_at) . ",
|
||||||
|
duration_ms = 0,
|
||||||
|
error_message = " . $this->sql($message) . "
|
||||||
|
WHERE id = $run_id AND status = 'queued'"
|
||||||
|
);
|
||||||
|
$updated = $this->affectedRows() === 1;
|
||||||
|
if (!$updated || $definition === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_state
|
||||||
|
SET last_status = 'skipped',
|
||||||
|
last_error = " . $this->sql($message) . "
|
||||||
|
WHERE task_id = " . $this->sql($definition->id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearClaimedLock(cron_task_definition $definition): void
|
||||||
|
{
|
||||||
|
$this->query(
|
||||||
|
"UPDATE cron_task_state
|
||||||
|
SET locked_until = NULL,
|
||||||
|
lock_owner = NULL
|
||||||
|
WHERE task_id = " . $this->sql($definition->id) . "
|
||||||
|
AND lock_owner = " . $this->sql($this->lock_owner)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
bool $force = false
|
||||||
|
): int {
|
||||||
|
$this->query(
|
||||||
|
"INSERT INTO cron_task_runs
|
||||||
|
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
|
||||||
|
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) . ', '
|
||||||
|
. ($force ? '1' : '0')
|
||||||
|
. ")"
|
||||||
|
);
|
||||||
|
|
||||||
|
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 publicRun(array $run): array
|
||||||
|
{
|
||||||
|
if ($run === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$run['force_run'] = (bool)($run['force_run'] ?? false);
|
||||||
|
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,117 @@
|
|||||||
|
<?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,
|
||||||
|
force_run TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
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::ensureColumn('cron_task_runs', 'force_run', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER lock_owner');
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS cron_worker_state (
|
||||||
|
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(191) NOT NULL,
|
||||||
|
hostname VARCHAR(191) NULL,
|
||||||
|
pid INT UNSIGNED NULL,
|
||||||
|
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'starting',
|
||||||
|
release_channel_id BIGINT UNSIGNED NULL,
|
||||||
|
release_target_id BIGINT UNSIGNED NULL,
|
||||||
|
coolify_resource_uuid VARCHAR(128) NULL,
|
||||||
|
coolify_resource_type VARCHAR(32) NULL,
|
||||||
|
commit_sha VARCHAR(64) NULL,
|
||||||
|
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
|
||||||
|
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
last_heartbeat_at DATETIME NULL,
|
||||||
|
last_loop_started_at DATETIME NULL,
|
||||||
|
last_loop_finished_at DATETIME NULL,
|
||||||
|
stopped_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
|
||||||
|
KEY idx_cron_worker_state_status (status),
|
||||||
|
KEY idx_cron_worker_state_release_target (release_target_id),
|
||||||
|
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||||
|
if ($table === '' || $column === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||||
|
if ($result && $result->num_rows > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class cron_worker
|
||||||
|
{
|
||||||
|
private cron_scheduler $scheduler;
|
||||||
|
private string $worker_id;
|
||||||
|
private string $name;
|
||||||
|
private string $source;
|
||||||
|
private int $poll_seconds;
|
||||||
|
private int $heartbeat_seconds;
|
||||||
|
private int $max_runtime_seconds;
|
||||||
|
private bool $should_stop = false;
|
||||||
|
private int $last_heartbeat = 0;
|
||||||
|
|
||||||
|
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
|
||||||
|
{
|
||||||
|
$this->scheduler = $scheduler ?? new cron_scheduler();
|
||||||
|
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
|
||||||
|
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
|
||||||
|
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
|
||||||
|
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
|
||||||
|
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
|
||||||
|
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): int
|
||||||
|
{
|
||||||
|
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
|
||||||
|
$this->heartbeat('disabled', 0, 0, null, true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->registerSignalHandlers();
|
||||||
|
$started = time();
|
||||||
|
$this->heartbeat('starting', 0, 0, null, true);
|
||||||
|
|
||||||
|
while (!$this->should_stop) {
|
||||||
|
$result = $this->tick();
|
||||||
|
$this->writeStatusLine($result);
|
||||||
|
|
||||||
|
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
|
||||||
|
$this->should_stop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sleepUntilNextPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->heartbeat('stopped', 0, 0, null, true, true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tick(): array
|
||||||
|
{
|
||||||
|
$this->heartbeat('running');
|
||||||
|
$loopStartedAt = date('Y-m-d H:i:s');
|
||||||
|
$staleRuns = 0;
|
||||||
|
$ran = ['count' => 0, 'ran' => []];
|
||||||
|
$error = null;
|
||||||
|
$status = 'running';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$staleRuns = $this->scheduler->markExpiredRunningRuns();
|
||||||
|
$ran = $this->scheduler->runDue($this->source);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$status = 'failed';
|
||||||
|
$error = $throwable->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'worker_id' => $this->worker_id,
|
||||||
|
'status' => $status,
|
||||||
|
'ran' => (int)($ran['count'] ?? 0),
|
||||||
|
'stale_runs' => $staleRuns,
|
||||||
|
'error' => $error,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listWorkers(): array
|
||||||
|
{
|
||||||
|
cron_schema_bootstrap::ensureTables();
|
||||||
|
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
|
||||||
|
$workers = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$workers[] = $this->publicWorker($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workers' => $workers,
|
||||||
|
'summary' => [
|
||||||
|
'total' => count($workers),
|
||||||
|
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
|
||||||
|
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerSignalHandlers(): void
|
||||||
|
{
|
||||||
|
if (!function_exists('pcntl_signal')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('pcntl_async_signals')) {
|
||||||
|
pcntl_async_signals(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pcntl_signal(SIGTERM, function (): void {
|
||||||
|
$this->should_stop = true;
|
||||||
|
});
|
||||||
|
pcntl_signal(SIGINT, function (): void {
|
||||||
|
$this->should_stop = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sleepUntilNextPoll(): void
|
||||||
|
{
|
||||||
|
$remaining = $this->poll_seconds;
|
||||||
|
while ($remaining > 0 && !$this->should_stop) {
|
||||||
|
$sleep = min(1, $remaining);
|
||||||
|
sleep($sleep);
|
||||||
|
$remaining -= $sleep;
|
||||||
|
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
|
||||||
|
$this->heartbeat('running');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function heartbeat(
|
||||||
|
string $status,
|
||||||
|
int $runCount = 0,
|
||||||
|
int $staleRunCount = 0,
|
||||||
|
?string $error = null,
|
||||||
|
bool $force = false,
|
||||||
|
bool $stopped = false,
|
||||||
|
?string $loopStartedAt = null
|
||||||
|
): void {
|
||||||
|
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cron_schema_bootstrap::ensureTables();
|
||||||
|
$this->last_heartbeat = time();
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$workerId = $this->sql($this->worker_id);
|
||||||
|
$name = $this->sql($this->name);
|
||||||
|
$hostname = $this->nullableSql(gethostname() ?: null);
|
||||||
|
$pid = getmypid() ?: 0;
|
||||||
|
$source = $this->sql($this->source);
|
||||||
|
$statusSql = $this->sql($status);
|
||||||
|
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
|
||||||
|
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
|
||||||
|
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
|
||||||
|
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
|
||||||
|
$commitSha = $this->nullableSql($this->commitSha());
|
||||||
|
$errorSql = $this->nullableSql($error);
|
||||||
|
$loopStarted = $this->nullableSql($loopStartedAt);
|
||||||
|
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
|
||||||
|
|
||||||
|
$this->query(
|
||||||
|
"INSERT INTO cron_worker_state (
|
||||||
|
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
|
||||||
|
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
|
||||||
|
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
|
||||||
|
last_loop_finished_at, stopped_at
|
||||||
|
) VALUES (
|
||||||
|
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
|
||||||
|
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
|
||||||
|
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted,
|
||||||
|
$this->sql($now), $stoppedAt
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
hostname = VALUES(hostname),
|
||||||
|
pid = VALUES(pid),
|
||||||
|
source = VALUES(source),
|
||||||
|
status = VALUES(status),
|
||||||
|
release_channel_id = VALUES(release_channel_id),
|
||||||
|
release_target_id = VALUES(release_target_id),
|
||||||
|
coolify_resource_uuid = VALUES(coolify_resource_uuid),
|
||||||
|
coolify_resource_type = VALUES(coolify_resource_type),
|
||||||
|
commit_sha = VALUES(commit_sha),
|
||||||
|
poll_seconds = VALUES(poll_seconds),
|
||||||
|
last_run_count = VALUES(last_run_count),
|
||||||
|
last_stale_run_count = VALUES(last_stale_run_count),
|
||||||
|
last_error = VALUES(last_error),
|
||||||
|
last_heartbeat_at = VALUES(last_heartbeat_at),
|
||||||
|
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
|
||||||
|
last_loop_finished_at = VALUES(last_loop_finished_at),
|
||||||
|
stopped_at = VALUES(stopped_at)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publicWorker(array $row): array
|
||||||
|
{
|
||||||
|
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
|
||||||
|
$heartbeatTs = strtotime($heartbeatAt);
|
||||||
|
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
|
||||||
|
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'worker_id' => (string)($row['worker_id'] ?? ''),
|
||||||
|
'name' => (string)($row['name'] ?? ''),
|
||||||
|
'hostname' => $row['hostname'] ?? null,
|
||||||
|
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
|
||||||
|
'source' => (string)($row['source'] ?? ''),
|
||||||
|
'status' => (string)($row['status'] ?? 'unknown'),
|
||||||
|
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
|
||||||
|
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
|
||||||
|
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
|
||||||
|
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
|
||||||
|
'commit_sha' => $row['commit_sha'] ?? null,
|
||||||
|
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
|
||||||
|
'last_run_count' => (int)($row['last_run_count'] ?? 0),
|
||||||
|
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
|
||||||
|
'last_error' => $row['last_error'] ?? null,
|
||||||
|
'started_at' => $row['started_at'] ?? null,
|
||||||
|
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
|
||||||
|
'last_heartbeat_age_seconds' => $age,
|
||||||
|
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
|
||||||
|
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
|
||||||
|
'stopped_at' => $row['stopped_at'] ?? null,
|
||||||
|
'stale' => $age === null || $age > $threshold,
|
||||||
|
'stale_after_seconds' => $threshold,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeStatusLine(array $result): void
|
||||||
|
{
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
|
||||||
|
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||||
|
. PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringOption(array $options, string $key, string $env, string $default): string
|
||||||
|
{
|
||||||
|
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
|
||||||
|
return $value !== '' ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
|
||||||
|
{
|
||||||
|
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
|
||||||
|
return max($min, min($max, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function boolOption(string $env, bool $default): bool
|
||||||
|
{
|
||||||
|
$value = $this->env($env);
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commitSha(): string
|
||||||
|
{
|
||||||
|
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
|
||||||
|
$value = trim((string)($this->env($key) ?? ''));
|
||||||
|
if ($value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function env(string $key): ?string
|
||||||
|
{
|
||||||
|
$value = getenv($key);
|
||||||
|
if ($value !== false) {
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableInt(?string $value): string
|
||||||
|
{
|
||||||
|
$value = trim((string)$value);
|
||||||
|
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
|
||||||
|
return 'NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string)max(0, (int)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableSql(?string $value): string
|
||||||
|
{
|
||||||
|
$value = $value !== null ? trim($value) : '';
|
||||||
|
return $value === '' ? 'NULL' : $this->sql($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) . "'";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,6 @@ use RuntimeException;
|
|||||||
|
|
||||||
class customer_order_product_policy
|
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
|
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||||
{
|
{
|
||||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||||
@@ -19,80 +16,31 @@ class customer_order_product_policy
|
|||||||
|
|
||||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||||
{
|
{
|
||||||
$context = self::loadOrderProductContext($orderId, $productId);
|
$customerNumber = self::loadOrderCustomerNumber($orderId);
|
||||||
if ($context === null) {
|
if ($customerNumber === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
$violation = (new customer_rule_product_restriction_service())
|
||||||
return null;
|
->violationForCustomerProduct($customerNumber, $productId);
|
||||||
}
|
return $violation === null ? null : (string)$violation['message'];
|
||||||
|
|
||||||
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
|
private static function loadOrderCustomerNumber(int $orderId): ?int
|
||||||
{
|
|
||||||
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;
|
global $db;
|
||||||
|
|
||||||
if ($orderId < 1 || $productId < 1) {
|
if ($orderId < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sql = "
|
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
|
||||||
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) {
|
if (!$result || $result->num_rows < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
return is_array($row) ? $row : null;
|
$customerNumber = (int)($row['customer_id'] ?? 0);
|
||||||
|
return $customerNumber > 0 ? $customerNumber : 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,13 @@
|
|||||||
namespace classes;
|
namespace classes;
|
||||||
|
|
||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
use objects\products_o;
|
|
||||||
use objects\users_o;
|
|
||||||
|
|
||||||
class customer_product_rule_service
|
class customer_product_rule_service
|
||||||
{
|
{
|
||||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{rule:string,message:string}|null
|
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
|
||||||
*/
|
*/
|
||||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||||
{
|
{
|
||||||
@@ -22,137 +17,17 @@ class customer_product_rule_service
|
|||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
|
||||||
$product = (new products_o())->getProductById($productId);
|
(int)$order->customer_id->value(),
|
||||||
if (!$product->exists()) {
|
$productId
|
||||||
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->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
|
|
||||||
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 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) {
|
if ($violation === null) {
|
||||||
return 0;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = $result->fetch_assoc();
|
// Keep the singular key during the API migration for existing invoice
|
||||||
return (int)($row['item_count'] ?? 0);
|
// and logging consumers while also returning every matching rule.
|
||||||
|
return ['rule' => (string)$violation['rules'][0]] + $violation;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additive schema and the one-time legacy-to-exact-product migration for
|
||||||
|
* customer-rule product restrictions.
|
||||||
|
*/
|
||||||
|
class customer_rule_product_restriction_schema_bootstrap
|
||||||
|
{
|
||||||
|
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
|
||||||
|
|
||||||
|
private static bool $initialized = false;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private const RULES = [
|
||||||
|
'restrictAdditionalServices' => 'Additional services',
|
||||||
|
'restrictTankCleaning' => 'Tank cleaning',
|
||||||
|
'restrictSpotFree' => 'SpotFree',
|
||||||
|
'restrictInteriorCleaning' => 'Interior cleaning',
|
||||||
|
'onlyTankCleaning' => 'Non-tank products',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function ensureSchema(): void
|
||||||
|
{
|
||||||
|
if (self::$initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::createTables($db);
|
||||||
|
self::deduplicateCustomerAttributes($db);
|
||||||
|
self::seedLegacyProductSets($db);
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createTables(object $db): void
|
||||||
|
{
|
||||||
|
$statements = [
|
||||||
|
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
|
||||||
|
attribute VARCHAR(191) NOT NULL,
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (attribute)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
attribute VARCHAR(191) NOT NULL,
|
||||||
|
name VARCHAR(191) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_customer_rule_collection_name (attribute, name),
|
||||||
|
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
|
||||||
|
CONSTRAINT fk_customer_rule_collection_attribute
|
||||||
|
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
|
||||||
|
collection_id INT UNSIGNED NOT NULL,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (collection_id, product_id),
|
||||||
|
KEY idx_customer_rule_collection_product (product_id, collection_id),
|
||||||
|
CONSTRAINT fk_customer_rule_collection_product_collection
|
||||||
|
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
|
||||||
|
migration_key VARCHAR(191) NOT NULL,
|
||||||
|
details_json LONGTEXT NULL,
|
||||||
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (migration_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
actor_user_id INT UNSIGNED NULL,
|
||||||
|
attribute VARCHAR(191) NOT NULL,
|
||||||
|
old_version INT UNSIGNED NOT NULL,
|
||||||
|
new_version INT UNSIGNED NOT NULL,
|
||||||
|
changes_json LONGTEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
|
||||||
|
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
if ($db->query($statement) === false) {
|
||||||
|
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function deduplicateCustomerAttributes(object $db): void
|
||||||
|
{
|
||||||
|
if (!self::tableExists($db, 'customer_attributes')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db->query(
|
||||||
|
'DELETE duplicate_row FROM customer_attributes duplicate_row
|
||||||
|
INNER JOIN customer_attributes keep_row
|
||||||
|
ON keep_row.user_id = duplicate_row.user_id
|
||||||
|
AND keep_row.attribute = duplicate_row.attribute
|
||||||
|
AND keep_row.id < duplicate_row.id'
|
||||||
|
) === false) {
|
||||||
|
throw new RuntimeException('Unable to deduplicate customer attributes');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db->query(
|
||||||
|
'ALTER TABLE customer_attributes
|
||||||
|
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
|
||||||
|
) === false) {
|
||||||
|
throw new RuntimeException('Unable to enforce unique customer attributes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function seedLegacyProductSets(object $db): void
|
||||||
|
{
|
||||||
|
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
|
||||||
|
$existing = $db->query(
|
||||||
|
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
|
||||||
|
);
|
||||||
|
if ($existing && (int)$existing->num_rows > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db->query('START TRANSACTION') === false) {
|
||||||
|
throw new RuntimeException('Unable to start customer-rule product migration');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ($db->query(
|
||||||
|
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
|
||||||
|
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
|
||||||
|
) === false) {
|
||||||
|
throw new RuntimeException('Unable to claim customer-rule product migration');
|
||||||
|
}
|
||||||
|
if (self::affectedRows($db) === 0) {
|
||||||
|
$db->query('ROLLBACK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys(self::RULES) as $attribute) {
|
||||||
|
$safeAttribute = self::escape($db, $attribute);
|
||||||
|
if ($db->query(
|
||||||
|
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
|
||||||
|
VALUES ('{$safeAttribute}', 1)"
|
||||||
|
) === false) {
|
||||||
|
throw new RuntimeException("Unable to initialize restriction {$attribute}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = [];
|
||||||
|
$seededProductIds = [];
|
||||||
|
foreach (self::RULES as $attribute => $collectionName) {
|
||||||
|
$safeAttribute = self::escape($db, $attribute);
|
||||||
|
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
|
||||||
|
if ($db->query(
|
||||||
|
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||||
|
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
|
||||||
|
) === false) {
|
||||||
|
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||||
|
}
|
||||||
|
$collectionId = (int)$db->insert_id();
|
||||||
|
if ($collectionId < 1) {
|
||||||
|
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$predicate = self::legacyPredicate($db, $attribute);
|
||||||
|
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
|
||||||
|
? 'p.deleted_at IS NULL'
|
||||||
|
: '1 = 1';
|
||||||
|
$insert = $db->query(
|
||||||
|
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||||
|
SELECT {$collectionId}, p.id
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN categories c ON c.id = p.category
|
||||||
|
WHERE ({$activePredicate}) AND ({$predicate})"
|
||||||
|
);
|
||||||
|
if ($insert === false) {
|
||||||
|
throw new RuntimeException("Unable to seed products for {$attribute}");
|
||||||
|
}
|
||||||
|
$counts[$attribute] = self::affectedRows($db);
|
||||||
|
$seeded = $db->query(
|
||||||
|
"SELECT product_id FROM customer_rule_product_collection_products
|
||||||
|
WHERE collection_id = {$collectionId} ORDER BY product_id"
|
||||||
|
);
|
||||||
|
$seededProductIds[$attribute] = [];
|
||||||
|
if ($seeded) {
|
||||||
|
while ($row = $seeded->fetch_assoc()) {
|
||||||
|
$seededProductIds[$attribute][] = (int)$row['product_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = self::escape($db, (string)json_encode([
|
||||||
|
'counts' => $counts,
|
||||||
|
'product_ids' => $seededProductIds,
|
||||||
|
'seeded_at' => gmdate(DATE_ATOM),
|
||||||
|
], JSON_UNESCAPED_SLASHES));
|
||||||
|
if ($db->query(
|
||||||
|
"UPDATE customer_rule_product_migrations
|
||||||
|
SET details_json = '{$details}', applied_at = NOW()
|
||||||
|
WHERE migration_key = '{$migrationKey}'"
|
||||||
|
) === false) {
|
||||||
|
throw new RuntimeException('Unable to record customer-rule product migration');
|
||||||
|
}
|
||||||
|
if ($db->query('COMMIT') === false) {
|
||||||
|
throw new RuntimeException('Unable to commit customer-rule product migration');
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$db->query('ROLLBACK');
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function legacyPredicate(object $db, string $attribute): string
|
||||||
|
{
|
||||||
|
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
|
||||||
|
|
||||||
|
return match ($attribute) {
|
||||||
|
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
|
||||||
|
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
|
||||||
|
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
|
||||||
|
: ''),
|
||||||
|
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
|
||||||
|
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
|
||||||
|
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
|
||||||
|
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
|
||||||
|
default => '0 = 1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tableExists(object $db, string $table): bool
|
||||||
|
{
|
||||||
|
$safeTable = self::escape($db, $table);
|
||||||
|
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
|
||||||
|
return $result && (int)$result->num_rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function indexExists(object $db, string $table, string $index): bool
|
||||||
|
{
|
||||||
|
$safeTable = str_replace('`', '', $table);
|
||||||
|
$safeIndex = self::escape($db, $index);
|
||||||
|
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
|
||||||
|
return $result && (int)$result->num_rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function columnExists(object $db, string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$safeTable = str_replace('`', '', $table);
|
||||||
|
$safeColumn = self::escape($db, $column);
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||||
|
return $result && (int)$result->num_rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function escape(object $db, string $value): string
|
||||||
|
{
|
||||||
|
return method_exists($db, 'escape_string')
|
||||||
|
? $db->escape_string($value)
|
||||||
|
: addslashes($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function affectedRows(object $db): int
|
||||||
|
{
|
||||||
|
if (method_exists($db, 'conn')) {
|
||||||
|
$connection = $db->conn();
|
||||||
|
return (int)($connection->affected_rows ?? 0);
|
||||||
|
}
|
||||||
|
return (int)($db->affected_rows ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class customer_rule_product_restriction_exception extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
$this->restrictionCode = $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string $restrictionCode;
|
||||||
|
|
||||||
|
public function httpStatus(): int
|
||||||
|
{
|
||||||
|
return $this->httpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restrictionCode(): string
|
||||||
|
{
|
||||||
|
return $this->restrictionCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source of truth for globally configured customer-rule product collections.
|
||||||
|
*/
|
||||||
|
class customer_rule_product_restriction_service
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public const PRODUCT_IMPACT_ATTRIBUTES = [
|
||||||
|
'restrictAdditionalServices',
|
||||||
|
'restrictTankCleaning',
|
||||||
|
'restrictSpotFree',
|
||||||
|
'restrictInteriorCleaning',
|
||||||
|
'onlyTankCleaning',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public const SUPPORTED_ATTRIBUTES = [
|
||||||
|
'restrictAdditionalServices',
|
||||||
|
'restrictTankCleaning',
|
||||||
|
'restrictSpotFree',
|
||||||
|
'restrictInteriorCleaning',
|
||||||
|
'onlyTankCleaning',
|
||||||
|
'requiresReferenceNumber',
|
||||||
|
'requiresRegistrationNumbersInvoice',
|
||||||
|
'invoiceAllOrdersIndividually',
|
||||||
|
'invoiceWithStripe',
|
||||||
|
'showPricesOnBookingPage',
|
||||||
|
'usePONumbers',
|
||||||
|
'exemptFromAdministrationFee',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
|
||||||
|
public function listConfiguration(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
|
||||||
|
'products' => $this->productCatalog(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
public function ruleConfiguration(string $attribute): array
|
||||||
|
{
|
||||||
|
$this->assertSupportedAttribute($attribute);
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$safeAttribute = $this->escape($attribute);
|
||||||
|
$versionResult = $db->query(
|
||||||
|
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
|
||||||
|
);
|
||||||
|
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||||
|
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
|
||||||
|
}
|
||||||
|
$versionRow = $versionResult->fetch_assoc();
|
||||||
|
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
|
||||||
|
FROM customer_rule_product_collections c
|
||||||
|
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||||
|
WHERE c.attribute = '{$safeAttribute}'
|
||||||
|
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$collections = [];
|
||||||
|
$disabled = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$collectionId = (int)$row['collection_id'];
|
||||||
|
if (!isset($collections[$collectionId])) {
|
||||||
|
$collections[$collectionId] = [
|
||||||
|
'id' => $collectionId,
|
||||||
|
'name' => (string)$row['name'],
|
||||||
|
'sort_order' => (int)$row['sort_order'],
|
||||||
|
'product_ids' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($row['product_id'] !== null) {
|
||||||
|
$productId = (int)$row['product_id'];
|
||||||
|
$collections[$collectionId]['product_ids'][] = $productId;
|
||||||
|
$disabled[$productId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'attribute' => $attribute,
|
||||||
|
'version' => max(1, (int)($versionRow['version'] ?? 1)),
|
||||||
|
'collections' => array_values($collections),
|
||||||
|
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
|
||||||
|
{
|
||||||
|
$this->assertSupportedAttribute($attribute);
|
||||||
|
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
|
||||||
|
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$safeAttribute = $this->escape($attribute);
|
||||||
|
if ($db->query('START TRANSACTION') === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$versionResult = $db->query(
|
||||||
|
"SELECT version FROM customer_rule_product_restrictions
|
||||||
|
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
|
||||||
|
);
|
||||||
|
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
|
||||||
|
}
|
||||||
|
$versionRow = $versionResult->fetch_assoc();
|
||||||
|
$currentVersion = (int)$versionRow['version'];
|
||||||
|
if ($currentVersion !== $expectedVersion) {
|
||||||
|
throw new customer_rule_product_restriction_exception(
|
||||||
|
'Customer rule configuration has changed; reload before saving',
|
||||||
|
409,
|
||||||
|
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$old = $this->ruleConfiguration($attribute);
|
||||||
|
$existingIds = $this->existingCollectionIds($attribute);
|
||||||
|
foreach ($collections as $collection) {
|
||||||
|
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
|
||||||
|
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid temporary unique-name collisions while two collections swap names.
|
||||||
|
foreach ($existingIds as $collectionId => $_) {
|
||||||
|
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
|
||||||
|
if ($db->query(
|
||||||
|
"UPDATE customer_rule_product_collections
|
||||||
|
SET name = '{$temporaryName}'
|
||||||
|
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||||
|
) === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$keptIds = [];
|
||||||
|
foreach ($collections as $collection) {
|
||||||
|
$name = $this->escape($collection['name']);
|
||||||
|
$sortOrder = (int)$collection['sort_order'];
|
||||||
|
$collectionId = $collection['id'];
|
||||||
|
if ($collectionId === null) {
|
||||||
|
if ($db->query(
|
||||||
|
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||||
|
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
|
||||||
|
) === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to create collection');
|
||||||
|
}
|
||||||
|
$collectionId = (int)$db->insert_id();
|
||||||
|
} else {
|
||||||
|
if ($db->query(
|
||||||
|
"UPDATE customer_rule_product_collections
|
||||||
|
SET name = '{$name}', sort_order = {$sortOrder}
|
||||||
|
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||||
|
) === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to update collection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$keptIds[$collectionId] = true;
|
||||||
|
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
foreach ($collection['product_ids'] as $productId) {
|
||||||
|
if ($db->query(
|
||||||
|
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||||
|
VALUES ({$collectionId}, {$productId})"
|
||||||
|
) === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to save collection products');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
|
||||||
|
if ($removeIds !== []) {
|
||||||
|
if ($db->query(
|
||||||
|
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
|
||||||
|
implode(',', array_map('intval', $removeIds)) . ')'
|
||||||
|
) === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newVersion = $currentVersion + 1;
|
||||||
|
if ($db->query(
|
||||||
|
"UPDATE customer_rule_product_restrictions
|
||||||
|
SET version = {$newVersion}, updated_at = NOW()
|
||||||
|
WHERE attribute = '{$safeAttribute}'"
|
||||||
|
) === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
$new = $this->ruleConfiguration($attribute);
|
||||||
|
$changes = $this->escape((string)json_encode([
|
||||||
|
'before' => $old,
|
||||||
|
'after' => $new,
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||||
|
if ($db->query(
|
||||||
|
"INSERT INTO customer_rule_product_audit_logs
|
||||||
|
(actor_user_id, attribute, old_version, new_version, changes_json)
|
||||||
|
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
|
||||||
|
) === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
if ($db->query('COMMIT') === false) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
return $new;
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$db->query('ROLLBACK');
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return configured product restrictions for all active product-impact
|
||||||
|
* attributes belonging to any account with the customer number.
|
||||||
|
*
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function restrictionsForCustomerNumber(int $customerNumber): array
|
||||||
|
{
|
||||||
|
if ($customerNumber < 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT DISTINCT ca.attribute
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN customer_attributes ca ON ca.user_id = u.id
|
||||||
|
WHERE u.customer_number = {$customerNumber}"
|
||||||
|
);
|
||||||
|
if (!$result) {
|
||||||
|
throw new RuntimeException('Unable to load active customer-rule product restrictions');
|
||||||
|
}
|
||||||
|
$activeAttributes = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$attribute = (string)$row['attribute'];
|
||||||
|
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||||
|
$activeAttributes[$attribute] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$active = [];
|
||||||
|
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
|
||||||
|
if (isset($activeAttributes[$attribute])) {
|
||||||
|
$active[] = $this->ruleConfiguration($attribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
|
||||||
|
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
|
||||||
|
{
|
||||||
|
if ($productId < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$rules = [];
|
||||||
|
$collections = [];
|
||||||
|
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||||
|
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rules[] = (string)$restriction['attribute'];
|
||||||
|
foreach ($restriction['collections'] as $collection) {
|
||||||
|
if (in_array($productId, $collection['product_ids'], true)) {
|
||||||
|
$collections[] = (int)$collection['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($rules === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
|
||||||
|
'message' => customer_product_rule_service::BLOCK_MESSAGE,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'rules' => array_values(array_unique($rules)),
|
||||||
|
'collections' => array_values(array_unique($collections)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $attributes
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function enrichAttributes(int $customerNumber, array $attributes): array
|
||||||
|
{
|
||||||
|
$restrictions = [];
|
||||||
|
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||||
|
$restrictions[(string)$restriction['attribute']] = [
|
||||||
|
'attribute' => (string)$restriction['attribute'],
|
||||||
|
'version' => (int)$restriction['version'],
|
||||||
|
'collections' => $restriction['collections'],
|
||||||
|
'disabled_product_ids' => $restriction['disabled_product_ids'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($attributes as &$attribute) {
|
||||||
|
$key = (string)($attribute['attribute'] ?? '');
|
||||||
|
$attribute['product_restriction'] = $restrictions[$key] ?? null;
|
||||||
|
}
|
||||||
|
unset($attribute);
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string,mixed>> */
|
||||||
|
private function productCatalog(): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$activeExpression = $this->columnExists('products', 'deleted_at')
|
||||||
|
? 'CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END'
|
||||||
|
: '1';
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
|
||||||
|
{$activeExpression} AS active
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN categories c ON c.id = p.category
|
||||||
|
ORDER BY c.name ASC, p.name ASC, p.id ASC"
|
||||||
|
);
|
||||||
|
if (!$result) {
|
||||||
|
throw new RuntimeException('Unable to load the customer-rule product catalog');
|
||||||
|
}
|
||||||
|
$products = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$products[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'name' => (string)$row['name'],
|
||||||
|
'category_id' => (int)$row['category_id'],
|
||||||
|
'category_name' => (string)($row['category_name'] ?? ''),
|
||||||
|
'active' => (bool)$row['active'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $products;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,true>
|
||||||
|
*/
|
||||||
|
private function existingCollectionIds(string $attribute): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$safeAttribute = $this->escape($attribute);
|
||||||
|
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
|
||||||
|
if (!$result) {
|
||||||
|
throw new RuntimeException("Unable to load existing collections for {$attribute}");
|
||||||
|
}
|
||||||
|
$ids = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$ids[(int)$row['id']] = true;
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
|
||||||
|
private function validateCollections(string $attribute, mixed $value): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
throw new customer_rule_product_restriction_exception('collections must be an array');
|
||||||
|
}
|
||||||
|
$normalized = [];
|
||||||
|
$names = [];
|
||||||
|
$collectionIds = [];
|
||||||
|
$allProductIds = [];
|
||||||
|
foreach (array_values($value) as $index => $collection) {
|
||||||
|
if (!is_array($collection)) {
|
||||||
|
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
|
||||||
|
}
|
||||||
|
$name = trim((string)($collection['name'] ?? ''));
|
||||||
|
if ($name === '' || mb_strlen($name) > 191) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
|
||||||
|
}
|
||||||
|
$nameKey = mb_strtolower($name);
|
||||||
|
if (isset($names[$nameKey])) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
|
||||||
|
}
|
||||||
|
$names[$nameKey] = true;
|
||||||
|
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
|
||||||
|
throw new customer_rule_product_restriction_exception('product_ids must be an array');
|
||||||
|
}
|
||||||
|
$productIds = [];
|
||||||
|
foreach ($collection['product_ids'] as $productId) {
|
||||||
|
$id = $this->positiveInt($productId, 'product_id');
|
||||||
|
$productIds[$id] = true;
|
||||||
|
$allProductIds[$id] = true;
|
||||||
|
}
|
||||||
|
$id = isset($collection['id']) && $collection['id'] !== null
|
||||||
|
? $this->positiveInt($collection['id'], 'collection id')
|
||||||
|
: null;
|
||||||
|
if ($id !== null && isset($collectionIds[$id])) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
|
||||||
|
}
|
||||||
|
if ($id !== null) {
|
||||||
|
$collectionIds[$id] = true;
|
||||||
|
}
|
||||||
|
$normalized[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
|
||||||
|
? (int)$collection['sort_order']
|
||||||
|
: $index,
|
||||||
|
'product_ids' => array_map('intval', array_keys($productIds)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<int> $productIds */
|
||||||
|
private function assertProductsExist(array $productIds): void
|
||||||
|
{
|
||||||
|
if ($productIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
global $db;
|
||||||
|
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
|
||||||
|
if (!$result) {
|
||||||
|
throw new customer_rule_product_restriction_exception(
|
||||||
|
'Unable to validate collection products',
|
||||||
|
500,
|
||||||
|
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$found = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$found[(int)$row['id']] = true;
|
||||||
|
}
|
||||||
|
$missing = array_values(array_diff($productIds, array_keys($found)));
|
||||||
|
if ($missing !== []) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertSupportedAttribute(string $attribute): void
|
||||||
|
{
|
||||||
|
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||||
|
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positiveInt(mixed $value, string $field): int
|
||||||
|
{
|
||||||
|
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
|
||||||
|
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
|
||||||
|
}
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escape(string $value): string
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnExists(string $table, string $column): bool
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$safeTable = str_replace('`', '', $table);
|
||||||
|
$safeColumn = $this->escape($column);
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||||
|
return $result && (int)$result->num_rows > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,15 +177,19 @@ class db
|
|||||||
return $this->database;
|
return $this->database;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPort(): int
|
||||||
|
{
|
||||||
|
return $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSslMode(): string
|
||||||
|
{
|
||||||
|
return $this->ssl_mode;
|
||||||
|
}
|
||||||
|
|
||||||
public function backupDatabase(string $path): bool
|
public function backupDatabase(string $path): bool
|
||||||
{
|
{
|
||||||
// Save the database to the path
|
|
||||||
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
|
|
||||||
$mode = strtoupper(trim($this->ssl_mode));
|
$mode = strtoupper(trim($this->ssl_mode));
|
||||||
// Map ssl_mode to MariaDB client flags
|
|
||||||
// DISABLED => --skip-ssl (no TLS)
|
|
||||||
// PREFERRED => (no flag; client decides)
|
|
||||||
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
|
|
||||||
$sslFlag = '';
|
$sslFlag = '';
|
||||||
switch ($mode) {
|
switch ($mode) {
|
||||||
case 'DISABLED':
|
case 'DISABLED':
|
||||||
@@ -201,17 +205,42 @@ class db
|
|||||||
$sslFlag = '--ssl';
|
$sslFlag = '--ssl';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = escapeshellarg($this->host);
|
$host = escapeshellarg($this->host);
|
||||||
$user = escapeshellarg($this->user);
|
$user = escapeshellarg($this->user);
|
||||||
$pass = escapeshellarg($this->password);
|
|
||||||
$db = escapeshellarg($this->database);
|
$db = escapeshellarg($this->database);
|
||||||
$port = (int)$this->port;
|
$port = (int)$this->port;
|
||||||
$outfile = escapeshellarg($path);
|
|
||||||
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
|
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
|
||||||
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
|
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
|
||||||
exec($command, $output, $return);
|
|
||||||
// Check if the command was successful
|
$directory = dirname($path);
|
||||||
return $return === 0;
|
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment = array_merge(getenv() ?: [], $_ENV);
|
||||||
|
$environment['MYSQL_PWD'] = $this->password;
|
||||||
|
$descriptors = [
|
||||||
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['file', $path, 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$process = proc_open($command, $descriptors, $pipes, null, $environment);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($pipes[0]);
|
||||||
|
$stderr = stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
$return = proc_close($process);
|
||||||
|
|
||||||
|
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
|
||||||
|
@file_put_contents($path . '.error.log', $stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $return === 0 && is_file($path) && filesize($path) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getView(string $view): array
|
public function getView(string $view): array
|
||||||
@@ -238,4 +267,4 @@ class db
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
|
|||||||
|
|
||||||
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
|
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->versioning = $versioning ?? new economic_v2_versioning_service();
|
||||||
$this->economic = $economic;
|
$this->economic = $economic;
|
||||||
}
|
}
|
||||||
@@ -388,7 +389,7 @@ class economic_v2_distribution_service
|
|||||||
continue;
|
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) {
|
if ($discount_row === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1528,7 +1529,7 @@ class economic_v2_distribution_service
|
|||||||
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
|
$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) {
|
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||||
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
|
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
|
||||||
$total += $line_price;
|
$total += $line_price;
|
||||||
@@ -1544,14 +1545,18 @@ class economic_v2_distribution_service
|
|||||||
return $total;
|
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)) {
|
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
|
||||||
return $this->discount_resolution_cache[$cache_key];
|
return $this->discount_resolution_cache[$cache_key];
|
||||||
}
|
}
|
||||||
|
|
||||||
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
|
$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 && (
|
if ($direct !== null && (
|
||||||
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|
||||||
|| (int)($direct['discount'] ?? 0) > 0
|
|| (int)($direct['discount'] ?? 0) > 0
|
||||||
@@ -1563,13 +1568,20 @@ class economic_v2_distribution_service
|
|||||||
if ($product !== null) {
|
if ($product !== null) {
|
||||||
$category = (string)$product->category->value();
|
$category = (string)$product->category->value();
|
||||||
if ($category !== '') {
|
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) {
|
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
|
||||||
return $this->discount_resolution_cache[$cache_key] = $category_discount;
|
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;
|
return $this->discount_resolution_cache[$cache_key] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class economic_v2_schema_bootstrap
|
|||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
|
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
department_id INT NULL DEFAULT NULL,
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
customer_number INT NOT NULL,
|
customer_number INT NOT NULL,
|
||||||
is_category TINYINT(1) NOT NULL,
|
is_category TINYINT(1) NOT NULL,
|
||||||
@@ -75,6 +76,7 @@ class economic_v2_schema_bootstrap
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_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_user (user_id),
|
||||||
INDEX idx_discount_override_versions_source (source)
|
INDEX idx_discount_override_versions_source (source)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
@@ -92,6 +94,14 @@ class economic_v2_schema_bootstrap
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
self::$initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ class economic_v2_versioning_service
|
|||||||
float $confidence = 1.0,
|
float $confidence = 1.0,
|
||||||
bool $inferred = false,
|
bool $inferred = false,
|
||||||
array $metadata = [],
|
array $metadata = [],
|
||||||
?int $fixed_price = null
|
?int $fixed_price = null,
|
||||||
|
?int $department_id = null
|
||||||
): array {
|
): array {
|
||||||
$identity = [
|
$identity = [
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
@@ -144,6 +145,9 @@ class economic_v2_versioning_service
|
|||||||
'is_category' => (int)$is_category,
|
'is_category' => (int)$is_category,
|
||||||
'object_id' => (string)$object_id,
|
'object_id' => (string)$object_id,
|
||||||
];
|
];
|
||||||
|
if ($department_id !== null) {
|
||||||
|
$identity['department_id'] = (int)$department_id;
|
||||||
|
}
|
||||||
|
|
||||||
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
|
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
|
||||||
return $this->closeActiveVersion(
|
return $this->closeActiveVersion(
|
||||||
@@ -240,15 +244,21 @@ class economic_v2_versioning_service
|
|||||||
int $customer_number,
|
int $customer_number,
|
||||||
bool $is_category,
|
bool $is_category,
|
||||||
int|string $object_id,
|
int|string $object_id,
|
||||||
string $timestamp
|
string $timestamp,
|
||||||
|
?int $department_id = null
|
||||||
): ?array {
|
): ?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(
|
$rows = $this->resolveActiveVersions(
|
||||||
'customer_discount_override_versions',
|
'customer_discount_override_versions',
|
||||||
[
|
$identity,
|
||||||
'customer_number' => $customer_number,
|
|
||||||
'is_category' => (int)$is_category,
|
|
||||||
'object_id' => (string)$object_id,
|
|
||||||
],
|
|
||||||
$timestamp,
|
$timestamp,
|
||||||
'effective_from DESC, id DESC',
|
'effective_from DESC, id DESC',
|
||||||
1
|
1
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a;
|
|||||||
use fxratesapi\fxratesapi_c;
|
use fxratesapi\fxratesapi_c;
|
||||||
use interfaces\fxratesapi_i;
|
use interfaces\fxratesapi_i;
|
||||||
use objects\fxratesapi_conversion_rates_o;
|
use objects\fxratesapi_conversion_rates_o;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class fxratesapi implements fxratesapi_i
|
class fxratesapi implements fxratesapi_i
|
||||||
{
|
{
|
||||||
@@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i
|
|||||||
// Validate the base and target currencies
|
// Validate the base and target currencies
|
||||||
self::requireValidCurrency($base);
|
self::requireValidCurrency($base);
|
||||||
self::requireValidCurrency($target);
|
self::requireValidCurrency($target);
|
||||||
// Validate the daily limit
|
|
||||||
self::requireDailyLimitNotExceeded();
|
|
||||||
// Validate the secret key
|
// Validate the secret key
|
||||||
self::requireValidSecretKey();
|
self::requireValidSecretKey();
|
||||||
|
// Reserve quota for the outbound provider call. Cached conversion reads return before this point.
|
||||||
|
$this->reserveRateFetchQuota($base, $target, $endpoint, $method);
|
||||||
// Send the request
|
// Send the request
|
||||||
$response = match ($method) {
|
$response = match ($method) {
|
||||||
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
|
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
|
||||||
@@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i
|
|||||||
function requireDailyLimitNotExceeded(): void
|
function requireDailyLimitNotExceeded(): void
|
||||||
{
|
{
|
||||||
// Check if the daily limit is exceeded
|
// Check if the daily limit is exceeded
|
||||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||||
throw new Exception('Daily limit exceeded');
|
throw new Exception('Daily limit exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i
|
|||||||
*/
|
*/
|
||||||
function getDailyRequestCounter(): int
|
function getDailyRequestCounter(): int
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
|
return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls');
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
// Count the rows from the fxratesapi request log that was made today
|
// Count the rows from the fxratesapi request log that was made today
|
||||||
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
|
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
|
||||||
$fxratesapi_lookups->getTodayCount();
|
$fxratesapi_lookups->getTodayCount();
|
||||||
return $fxratesapi_lookups->getTodayCount();
|
return $fxratesapi_lookups->getTodayCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void
|
||||||
|
{
|
||||||
|
(new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [
|
||||||
|
'base' => $base,
|
||||||
|
'target' => $target,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
@@ -515,4 +534,4 @@ class fxratesapi implements fxratesapi_i
|
|||||||
throw new Exception('Failed to add request to log');
|
throw new Exception('Failed to add request to log');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class invoice_period_flag_service
|
|||||||
{
|
{
|
||||||
invoice_period_flag_schema_bootstrap::ensureTables();
|
invoice_period_flag_schema_bootstrap::ensureTables();
|
||||||
price_overrides_schema_bootstrap::ensureColumns();
|
price_overrides_schema_bootstrap::ensureColumns();
|
||||||
|
department_customer_price_overrides_schema_bootstrap::ensureTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createManualFlag(array $payload, int $userId): array
|
public function createManualFlag(array $payload, int $userId): array
|
||||||
@@ -712,9 +713,21 @@ class invoice_period_flag_service
|
|||||||
p.max_quantity_per_order,
|
p.max_quantity_per_order,
|
||||||
c.name AS category_name,
|
c.name AS category_name,
|
||||||
pdp.price AS department_price,
|
pdp.price AS department_price,
|
||||||
product_discount.percentage AS product_discount_percentage,
|
CASE
|
||||||
product_discount.fixed_price AS product_fixed_price,
|
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
|
||||||
category_discount.percentage AS category_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
|
FROM orders o
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
|
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
|
||||||
@@ -745,6 +758,32 @@ class invoice_period_flag_service
|
|||||||
) category_discount
|
) category_discount
|
||||||
ON category_discount.customer_number = o.customer_id
|
ON category_discount.customer_number = o.customer_id
|
||||||
AND category_discount.product_or_category_id = p.category
|
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}'
|
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
|
||||||
AND o.deleted_at IS NULL
|
AND o.deleted_at IS NULL
|
||||||
ORDER BY o.customer_id, o.id, oi.id";
|
ORDER BY o.customer_id, o.id, oi.id";
|
||||||
@@ -808,11 +847,16 @@ class invoice_period_flag_service
|
|||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
|
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||||
|
|
||||||
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
|
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
|
||||||
$result = $db->query(
|
$result = $db->query(
|
||||||
"SELECT u.customer_number, ca.attribute
|
"SELECT u.customer_number, ca.attribute, cp.product_id
|
||||||
FROM customer_attributes ca
|
FROM customer_attributes ca
|
||||||
JOIN users u ON u.id = ca.user_id
|
JOIN users u ON u.id = ca.user_id
|
||||||
|
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
|
||||||
|
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
|
||||||
|
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||||
WHERE 1=1 {$customerFilter}"
|
WHERE 1=1 {$customerFilter}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -823,7 +867,11 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$customerNumber = (int)$row['customer_number'];
|
$customerNumber = (int)$row['customer_number'];
|
||||||
$attributes[$customerNumber][(string)$row['attribute']] = true;
|
$attribute = (string)$row['attribute'];
|
||||||
|
$attributes[$customerNumber][$attribute] = true;
|
||||||
|
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||||
|
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -834,6 +882,8 @@ class invoice_period_flag_service
|
|||||||
$flags = [];
|
$flags = [];
|
||||||
$orders = [];
|
$orders = [];
|
||||||
$collectionOrders = [];
|
$collectionOrders = [];
|
||||||
|
$matchingRules = static fn(int $customerNumber, int $productId): array =>
|
||||||
|
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$customerNumber = (int)$row['customer_number'];
|
$customerNumber = (int)$row['customer_number'];
|
||||||
@@ -859,57 +909,17 @@ class invoice_period_flag_service
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
|
$productRuleDefinitions = [
|
||||||
|
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
|
||||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
|
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
|
||||||
&& (int)($row['related_item_id'] ?? 0) > 0
|
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
|
||||||
&& (int)($row['item_price'] ?? 0) > 0) {
|
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
|
||||||
$flags[] = $this->automaticFlag(
|
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
|
||||||
'customer_rule_restrict_addon_services',
|
|
||||||
'order_item',
|
|
||||||
(int)$row['order_item_id'],
|
|
||||||
null,
|
|
||||||
$row,
|
|
||||||
['product' => $this->productLabel($row)],
|
|
||||||
$this->orderItemContext($row)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
|
|
||||||
$flags[] = $this->automaticFlag(
|
|
||||||
'customer_rule_restrict_tank_cleaning',
|
|
||||||
'order_item',
|
|
||||||
(int)$row['order_item_id'],
|
|
||||||
null,
|
|
||||||
$row,
|
|
||||||
['product' => $this->productLabel($row)],
|
|
||||||
$this->orderItemContext($row)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
|
|
||||||
$flags[] = $this->automaticFlag(
|
|
||||||
'customer_rule_only_tank_cleaning',
|
|
||||||
'order_item',
|
|
||||||
(int)$row['order_item_id'],
|
|
||||||
null,
|
|
||||||
$row,
|
|
||||||
['product' => $this->productLabel($row)],
|
|
||||||
$this->orderItemContext($row)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$restrictedProducts = [
|
|
||||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
|
|
||||||
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
|
|
||||||
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
|
|
||||||
];
|
];
|
||||||
|
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
|
||||||
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
|
if (isset($productRuleDefinitions[$attribute])) {
|
||||||
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
|
|
||||||
&& $this->rowMatchesProductTerms($row, $terms)) {
|
|
||||||
$flags[] = $this->automaticFlag(
|
$flags[] = $this->automaticFlag(
|
||||||
$definitionKey,
|
$productRuleDefinitions[$attribute],
|
||||||
'order_item',
|
'order_item',
|
||||||
(int)$row['order_item_id'],
|
(int)$row['order_item_id'],
|
||||||
null,
|
null,
|
||||||
@@ -919,6 +929,19 @@ class invoice_period_flag_service
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
|
||||||
|
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
|
||||||
|
$flags[] = $this->automaticFlag(
|
||||||
|
'customer_rule_exempt_from_administration_fees',
|
||||||
|
'order_item',
|
||||||
|
(int)$row['order_item_id'],
|
||||||
|
null,
|
||||||
|
$row,
|
||||||
|
['product' => $this->productLabel($row)],
|
||||||
|
$this->orderItemContext($row)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($orders as $orderId => $row) {
|
foreach ($orders as $orderId => $row) {
|
||||||
@@ -2070,11 +2093,6 @@ class invoice_period_flag_service
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function rowIsTankCleaningProduct(array $row): bool
|
|
||||||
{
|
|
||||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isIncludedOrderItem(array $row): bool
|
private function isIncludedOrderItem(array $row): bool
|
||||||
{
|
{
|
||||||
$value = $row['item_include_in_invoice'] ?? 1;
|
$value = $row['item_include_in_invoice'] ?? 1;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
|
|||||||
return count($objects['Contents'] ?? []) > 0;
|
return count($objects['Contents'] ?? []) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getInvoiceDownloadUrl(int $id): string
|
public function getInvoiceDownloadUrl(int|string $id): string
|
||||||
{
|
{
|
||||||
return self::getPresignedUrl('invoice_' . $id . '.pdf');
|
return self::getPresignedUrl('invoice_' . $id . '.pdf');
|
||||||
}
|
}
|
||||||
@@ -51,4 +51,4 @@ class invoice_store implements minio_invoices_i
|
|||||||
{
|
{
|
||||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,11 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
return (string)$configured;
|
return (string)$configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function configuredApiBaseUrl(): string
|
||||||
|
{
|
||||||
|
return self::normalizeApiUrl(self::configuredApiUrl());
|
||||||
|
}
|
||||||
|
|
||||||
private static function normalizeApiUrl(string $api_url): string
|
private static function normalizeApiUrl(string $api_url): string
|
||||||
{
|
{
|
||||||
$api_url = trim($api_url);
|
$api_url = trim($api_url);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class limited_backoffice_service
|
|||||||
{
|
{
|
||||||
public const PERMISSION_ACCESS = 'limited_backoffice_access';
|
public const PERMISSION_ACCESS = 'limited_backoffice_access';
|
||||||
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
|
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
|
||||||
|
public const PERMISSION_VIEW_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_view';
|
||||||
|
public const PERMISSION_MANAGE_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_manage';
|
||||||
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
|
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
|
||||||
|
|
||||||
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
|
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
|
||||||
@@ -251,6 +253,8 @@ class limited_backoffice_service
|
|||||||
'statistics_bookings_new',
|
'statistics_bookings_new',
|
||||||
self::PERMISSION_ACCESS,
|
self::PERMISSION_ACCESS,
|
||||||
self::PERMISSION_MANAGE_PRICES,
|
self::PERMISSION_MANAGE_PRICES,
|
||||||
|
self::PERMISSION_VIEW_CUSTOMER_PRICING,
|
||||||
|
self::PERMISSION_MANAGE_CUSTOMER_PRICING,
|
||||||
self::PERMISSION_MANAGE_EMPLOYEES,
|
self::PERMISSION_MANAGE_EMPLOYEES,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -484,6 +488,14 @@ class limited_backoffice_service
|
|||||||
'group' => 'limited_backoffice',
|
'group' => 'limited_backoffice',
|
||||||
'capability' => 'manage_department_prices',
|
'capability' => 'manage_department_prices',
|
||||||
],
|
],
|
||||||
|
self::PERMISSION_VIEW_CUSTOMER_PRICING => [
|
||||||
|
'group' => 'limited_backoffice',
|
||||||
|
'capability' => 'view_customer_pricing',
|
||||||
|
],
|
||||||
|
self::PERMISSION_MANAGE_CUSTOMER_PRICING => [
|
||||||
|
'group' => 'limited_backoffice',
|
||||||
|
'capability' => 'manage_customer_pricing',
|
||||||
|
],
|
||||||
self::PERMISSION_MANAGE_EMPLOYEES => [
|
self::PERMISSION_MANAGE_EMPLOYEES => [
|
||||||
'group' => 'limited_backoffice',
|
'group' => 'limited_backoffice',
|
||||||
'capability' => 'manage_employee_access',
|
'capability' => 'manage_employee_access',
|
||||||
@@ -795,6 +807,34 @@ class limited_backoffice_service
|
|||||||
return $this->getDepartmentPrices($user, $departmentId);
|
return $this->getDepartmentPrices($user, $departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId): array
|
||||||
|
{
|
||||||
|
$this->assertDepartmentAccess($user, $departmentId);
|
||||||
|
return (new department_customer_pricing_service())->getPricing($departmentId, $customerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function updateDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId, array $payload): array
|
||||||
|
{
|
||||||
|
$this->assertDepartmentAccess($user, $departmentId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new department_customer_pricing_service())->updatePricing($departmentId, $customerUserId, [
|
||||||
|
...$payload,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'user_id' => $customerUserId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class module_usage_registry
|
||||||
|
{
|
||||||
|
public const DEFAULT_SOFT_LIMIT_PERCENT = 90.0;
|
||||||
|
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
fn(array $descriptor): array => $this->withDefaults($descriptor),
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'module_key' => 'motorapi',
|
||||||
|
'module_label' => 'MotorAPI',
|
||||||
|
'metric_key' => 'lookup_calls',
|
||||||
|
'metric_label' => 'License plate lookups',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'day',
|
||||||
|
'source' => 'internal_counter',
|
||||||
|
'default_enforce_mode' => 'block',
|
||||||
|
'config_module' => 'motorapi',
|
||||||
|
'config_variable' => 'daily_limit',
|
||||||
|
'config_type' => 'int',
|
||||||
|
'legacy_count_table' => 'motorapi_lookups',
|
||||||
|
'primary' => true,
|
||||||
|
'writable_limit' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'motorapi',
|
||||||
|
'module_label' => 'MotorAPI',
|
||||||
|
'metric_key' => 'provider_usage',
|
||||||
|
'metric_label' => 'Provider usage',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'provider',
|
||||||
|
'source' => 'provider_snapshot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'fxratesapi',
|
||||||
|
'module_label' => 'FXRatesAPI',
|
||||||
|
'metric_key' => 'rate_fetch_calls',
|
||||||
|
'metric_label' => 'Currency rate fetches',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'day',
|
||||||
|
'source' => 'internal_counter',
|
||||||
|
'default_enforce_mode' => 'block',
|
||||||
|
'config_module' => 'fxratesapi',
|
||||||
|
'config_variable' => 'daily_limit',
|
||||||
|
'config_type' => 'int',
|
||||||
|
'legacy_count_table' => 'fxratesapi_conversion_rates',
|
||||||
|
'primary' => true,
|
||||||
|
'writable_limit' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'virkdata',
|
||||||
|
'module_label' => 'VirkData',
|
||||||
|
'metric_key' => 'company_search_calls',
|
||||||
|
'metric_label' => 'Company searches',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'month',
|
||||||
|
'source' => 'internal_counter',
|
||||||
|
'default_enforce_mode' => 'observe',
|
||||||
|
'config_module' => 'virkdata',
|
||||||
|
'config_variable' => 'monthly_limit',
|
||||||
|
'config_type' => 'int',
|
||||||
|
'legacy_log_module' => 'VIRKDATA',
|
||||||
|
'legacy_log_action' => 'VIRKDATA_SEARCH',
|
||||||
|
'primary' => true,
|
||||||
|
'writable_limit' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'licenseplaterecognizer',
|
||||||
|
'module_label' => 'License Plate Recognizer',
|
||||||
|
'metric_key' => 'plate_recognition_calls',
|
||||||
|
'metric_label' => 'Plate recognition calls',
|
||||||
|
'unit' => 'calls',
|
||||||
|
'period' => 'provider',
|
||||||
|
'source' => 'provider_snapshot',
|
||||||
|
'default_enforce_mode' => 'observe',
|
||||||
|
'primary' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'module_key' => 'email',
|
||||||
|
'module_label' => 'Email',
|
||||||
|
'metric_key' => 'mailersend_messages',
|
||||||
|
'metric_label' => 'MailerSend messages',
|
||||||
|
'unit' => 'messages',
|
||||||
|
'period' => 'provider',
|
||||||
|
'source' => 'provider_snapshot',
|
||||||
|
],
|
||||||
|
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'],
|
||||||
|
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'],
|
||||||
|
['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'],
|
||||||
|
['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'],
|
||||||
|
['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'],
|
||||||
|
['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'],
|
||||||
|
['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(string $moduleKey, string $metricKey): ?array
|
||||||
|
{
|
||||||
|
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||||
|
$metricKey = $this->normalizeMetricKey($metricKey);
|
||||||
|
|
||||||
|
foreach ($this->all() as $descriptor) {
|
||||||
|
if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) {
|
||||||
|
return $descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forModule(string $moduleKey): array
|
||||||
|
{
|
||||||
|
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->all(),
|
||||||
|
static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeModuleKey(string $moduleKey): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($moduleKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeMetricKey(string $metricKey): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($metricKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function withDefaults(array $descriptor): array
|
||||||
|
{
|
||||||
|
$descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']);
|
||||||
|
$descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']);
|
||||||
|
$descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']);
|
||||||
|
$descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']);
|
||||||
|
$descriptor['unit'] = (string)($descriptor['unit'] ?? 'count');
|
||||||
|
$descriptor['period'] = (string)($descriptor['period'] ?? 'all_time');
|
||||||
|
$descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global');
|
||||||
|
$descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? '');
|
||||||
|
$descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter');
|
||||||
|
$descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe');
|
||||||
|
$descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT);
|
||||||
|
$descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false);
|
||||||
|
$descriptor['primary'] = (bool)($descriptor['primary'] ?? false);
|
||||||
|
return $descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class module_usage_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 module_usage_counters (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module_key VARCHAR(64) NOT NULL,
|
||||||
|
metric_key VARCHAR(128) NOT NULL,
|
||||||
|
scope_type VARCHAR(32) NOT NULL DEFAULT 'global',
|
||||||
|
scope_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||||
|
period_key VARCHAR(32) NOT NULL DEFAULT 'all_time',
|
||||||
|
period_start DATETIME NOT NULL,
|
||||||
|
period_end DATETIME NULL,
|
||||||
|
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||||
|
used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0,
|
||||||
|
limit_quantity DECIMAL(20,4) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'ok',
|
||||||
|
metadata_json LONGTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start),
|
||||||
|
KEY idx_module_usage_counters_module_period (module_key, period_key, period_start),
|
||||||
|
KEY idx_module_usage_counters_status (status, updated_at),
|
||||||
|
KEY idx_module_usage_counters_scope (scope_type, scope_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_usage_snapshots (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module_key VARCHAR(64) NOT NULL,
|
||||||
|
metric_key VARCHAR(128) NOT NULL,
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||||
|
period_key VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||||
|
period_start DATETIME NULL,
|
||||||
|
period_end DATETIME NULL,
|
||||||
|
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||||
|
used_quantity DECIMAL(20,4) NULL,
|
||||||
|
limit_quantity DECIMAL(20,4) NULL,
|
||||||
|
remaining_quantity DECIMAL(20,4) NULL,
|
||||||
|
usage_percent DECIMAL(8,4) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||||
|
raw_payload_json LONGTEXT NULL,
|
||||||
|
checked_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at),
|
||||||
|
KEY idx_module_usage_snapshots_status (status, checked_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_quota_settings (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module_key VARCHAR(64) NOT NULL,
|
||||||
|
metric_key VARCHAR(128) NOT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||||
|
soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_module_quota_setting (module_key, metric_key),
|
||||||
|
KEY idx_module_quota_settings_mode (enforce_mode, enabled)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS module_usage_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
module VARCHAR(64) NOT NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
status_code INT NOT NULL DEFAULT 0,
|
||||||
|
data LONGTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_module_usage_logs_module_created (module, created_at),
|
||||||
|
KEY idx_module_usage_logs_action_created (action, created_at),
|
||||||
|
KEY idx_module_usage_logs_status_created (status_code, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key");
|
||||||
|
self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode");
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||||
|
if ($table === '' || $column === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||||
|
if ($result && $result->num_rows > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,997 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use mysqli_result;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class module_usage_service
|
||||||
|
{
|
||||||
|
private module_usage_registry $registry;
|
||||||
|
|
||||||
|
public function __construct(?module_usage_registry $registry = null)
|
||||||
|
{
|
||||||
|
module_usage_schema_bootstrap::ensureTables();
|
||||||
|
$this->registry = $registry ?? new module_usage_registry();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(array $filters = []): array
|
||||||
|
{
|
||||||
|
$moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : '';
|
||||||
|
$periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : '';
|
||||||
|
$statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : '';
|
||||||
|
$date = isset($filters['date']) ? (string)$filters['date'] : null;
|
||||||
|
|
||||||
|
$metrics = [];
|
||||||
|
foreach ($this->registry->all() as $descriptor) {
|
||||||
|
if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metric = $this->currentMetric($descriptor, $date);
|
||||||
|
if ($statusFilter !== '' && $metric['status'] !== $statusFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$metrics[] = $metric;
|
||||||
|
}
|
||||||
|
|
||||||
|
$modules = [];
|
||||||
|
foreach ($metrics as $metric) {
|
||||||
|
$moduleKey = $metric['module_key'];
|
||||||
|
if (!isset($modules[$moduleKey])) {
|
||||||
|
$modules[$moduleKey] = [
|
||||||
|
'key' => $moduleKey,
|
||||||
|
'label' => $metric['module_label'],
|
||||||
|
'status' => 'ok',
|
||||||
|
'metrics' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$modules[$moduleKey]['metrics'][] = $metric;
|
||||||
|
$modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'filters' => [
|
||||||
|
'module' => $moduleFilter !== '' ? $moduleFilter : null,
|
||||||
|
'period' => $periodFilter !== '' ? $periodFilter : null,
|
||||||
|
'status' => $statusFilter !== '' ? $statusFilter : null,
|
||||||
|
'date' => $date,
|
||||||
|
],
|
||||||
|
'modules' => array_values($modules),
|
||||||
|
'metrics' => $metrics,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function moduleDetail(string $moduleKey, array $filters = []): array
|
||||||
|
{
|
||||||
|
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||||
|
$descriptors = $this->registry->forModule($moduleKey);
|
||||||
|
$date = isset($filters['date']) ? (string)$filters['date'] : null;
|
||||||
|
$metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'module_key' => $moduleKey,
|
||||||
|
'metrics' => $metrics,
|
||||||
|
'history' => $this->historyForModule($moduleKey, $filters),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function metricsForModule(string $moduleKey): array
|
||||||
|
{
|
||||||
|
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||||
|
return array_map(
|
||||||
|
fn(array $descriptor): array => $this->currentMetric($descriptor),
|
||||||
|
$this->registry->forModule($moduleKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically records usage and enforces blocking quotas when the metric is configured for block mode.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
|
||||||
|
{
|
||||||
|
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||||
|
$quantity = max(0.0, $quantity);
|
||||||
|
if ($quantity <= 0.0) {
|
||||||
|
return $this->currentMetric($descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = $this->settingFor($descriptor);
|
||||||
|
if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') {
|
||||||
|
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $this->resolveLimitQuantity($descriptor);
|
||||||
|
if ($limit === null) {
|
||||||
|
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
$period = $this->periodWindow((string)$descriptor['period']);
|
||||||
|
$this->insertCounterIfMissing($descriptor, $period, $limit);
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$where = $this->counterWhereSql($descriptor, $period);
|
||||||
|
$quantitySql = $this->numberSql($quantity);
|
||||||
|
$limitSql = $this->numberSql($limit);
|
||||||
|
$metadataSql = $this->jsonSql($metadata);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"UPDATE module_usage_counters
|
||||||
|
SET used_quantity = used_quantity + {$quantitySql},
|
||||||
|
limit_quantity = {$limitSql},
|
||||||
|
metadata_json = {$metadataSql},
|
||||||
|
status = CASE
|
||||||
|
WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted'
|
||||||
|
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit'
|
||||||
|
ELSE 'ok'
|
||||||
|
END
|
||||||
|
WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((int)$db->conn()->affected_rows <= 0) {
|
||||||
|
throw new Exception($this->quotaExceededMessage($descriptor));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->currentMetric($descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
|
||||||
|
{
|
||||||
|
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||||
|
$quantity = max(0.0, $quantity);
|
||||||
|
if ($quantity <= 0.0 || !$this->databaseReady()) {
|
||||||
|
return $this->currentMetric($descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $this->resolveLimitQuantity($descriptor);
|
||||||
|
$setting = $this->settingFor($descriptor);
|
||||||
|
$period = $this->periodWindow((string)$descriptor['period']);
|
||||||
|
$this->insertCounterIfMissing($descriptor, $period, $limit);
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$where = $this->counterWhereSql($descriptor, $period);
|
||||||
|
$quantitySql = $this->numberSql($quantity);
|
||||||
|
$limitSql = $this->nullableNumberSql($limit);
|
||||||
|
$metadataSql = $this->jsonSql($metadata);
|
||||||
|
$softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']);
|
||||||
|
$statusSql = (($setting['enabled'] ?? true) !== true)
|
||||||
|
? $this->sqlString('disabled')
|
||||||
|
: "CASE
|
||||||
|
WHEN {$limitSql} IS NULL THEN 'unlimited'
|
||||||
|
WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted'
|
||||||
|
WHEN {$limitSql} <= 0 THEN 'ok'
|
||||||
|
WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted'
|
||||||
|
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit'
|
||||||
|
ELSE 'ok'
|
||||||
|
END";
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"UPDATE module_usage_counters
|
||||||
|
SET used_quantity = used_quantity + {$quantitySql},
|
||||||
|
limit_quantity = {$limitSql},
|
||||||
|
metadata_json = {$metadataSql},
|
||||||
|
status = {$statusSql}
|
||||||
|
WHERE {$where}"
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->currentMetric($descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentUsedQuantity(string $moduleKey, string $metricKey): int
|
||||||
|
{
|
||||||
|
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||||
|
$metric = $this->currentMetric($descriptor);
|
||||||
|
return (int)floor((float)($metric['used'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array
|
||||||
|
{
|
||||||
|
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||||
|
$setting = $this->settingFor($descriptor);
|
||||||
|
|
||||||
|
$enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled'];
|
||||||
|
$enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode'];
|
||||||
|
if (!in_array($enforceMode, ['observe', 'block'], true)) {
|
||||||
|
throw new Exception('Invalid enforce mode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$softLimitPercent = array_key_exists('soft_limit_percent', $payload)
|
||||||
|
? (float)$payload['soft_limit_percent']
|
||||||
|
: (float)$setting['soft_limit_percent'];
|
||||||
|
if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) {
|
||||||
|
throw new Exception('Soft limit percent must be between 1 and 100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) {
|
||||||
|
if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) {
|
||||||
|
throw new Exception('quota_not_writable');
|
||||||
|
}
|
||||||
|
$limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity'];
|
||||||
|
if (!is_numeric($limitValue) || (int)$limitValue < 0) {
|
||||||
|
throw new Exception('Limit must be a non-negative integer.');
|
||||||
|
}
|
||||||
|
$this->writeConfigLimit($descriptor, (int)$limitValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->databaseReady()) {
|
||||||
|
global $db;
|
||||||
|
$moduleKeySql = $this->sqlString((string)$descriptor['module_key']);
|
||||||
|
$metricKeySql = $this->sqlString((string)$descriptor['metric_key']);
|
||||||
|
$enabledSql = $enabled ? '1' : '0';
|
||||||
|
$modeSql = $this->sqlString($enforceMode);
|
||||||
|
$softLimitSql = $this->numberSql($softLimitPercent);
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent)
|
||||||
|
VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql})
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
enabled = VALUES(enabled),
|
||||||
|
enforce_mode = VALUES(enforce_mode),
|
||||||
|
soft_limit_percent = VALUES(soft_limit_percent)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->currentMetric($descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array
|
||||||
|
{
|
||||||
|
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||||
|
$descriptor = null;
|
||||||
|
foreach ($this->registry->forModule($moduleKey) as $candidate) {
|
||||||
|
if (($candidate['source'] ?? '') === 'provider_snapshot') {
|
||||||
|
$descriptor = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($descriptor === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']);
|
||||||
|
$limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']);
|
||||||
|
$remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']);
|
||||||
|
$percent = $this->firstNumeric($usage, ['usage_percent', 'percent']);
|
||||||
|
$usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']);
|
||||||
|
$unavailableReason = trim((string)($usage['unavailable_reason'] ?? ''));
|
||||||
|
|
||||||
|
if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remaining === null && $used !== null && $limit !== null) {
|
||||||
|
$remaining = max(0.0, $limit - $used);
|
||||||
|
}
|
||||||
|
if ($percent === null && $used !== null && $limit !== null && $limit > 0) {
|
||||||
|
$percent = round(($used / $limit) * 100, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (!$usageAvailable || $unavailableReason !== '')
|
||||||
|
? 'unknown'
|
||||||
|
: $this->statusForUsage($used, $limit, $this->settingFor($descriptor));
|
||||||
|
$payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]);
|
||||||
|
|
||||||
|
if ($this->databaseReady()) {
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO module_usage_snapshots
|
||||||
|
(module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at)
|
||||||
|
VALUES (
|
||||||
|
" . $this->sqlString((string)$descriptor['module_key']) . ",
|
||||||
|
" . $this->sqlString((string)$descriptor['metric_key']) . ",
|
||||||
|
'provider',
|
||||||
|
'provider',
|
||||||
|
" . $this->sqlString((string)$descriptor['unit']) . ",
|
||||||
|
" . $this->nullableNumberSql($used) . ",
|
||||||
|
" . $this->nullableNumberSql($limit) . ",
|
||||||
|
" . $this->nullableNumberSql($remaining) . ",
|
||||||
|
" . $this->nullableNumberSql($percent) . ",
|
||||||
|
" . $this->sqlString($status) . ",
|
||||||
|
" . $this->jsonSql($payload) . ",
|
||||||
|
" . $this->sqlString(date('Y-m-d H:i:s')) . "
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Provider snapshots are observability data. They must never break probes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function primarySystemUsage(array $metrics): ?array
|
||||||
|
{
|
||||||
|
$metrics = array_values(array_filter(
|
||||||
|
$metrics,
|
||||||
|
static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null
|
||||||
|
));
|
||||||
|
if ($metrics === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($metrics, function (array $left, array $right): int {
|
||||||
|
if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) {
|
||||||
|
return ($right['primary'] ?? false) <=> ($left['primary'] ?? false);
|
||||||
|
}
|
||||||
|
$leftRank = $this->statusRank((string)($left['status'] ?? 'unknown'));
|
||||||
|
$rightRank = $this->statusRank((string)($right['status'] ?? 'unknown'));
|
||||||
|
return $rightRank <=> $leftRank;
|
||||||
|
});
|
||||||
|
|
||||||
|
$metric = $metrics[0];
|
||||||
|
return [
|
||||||
|
'provider' => $metric['module_key'],
|
||||||
|
'metric_key' => $metric['metric_key'],
|
||||||
|
'unit' => $metric['unit'],
|
||||||
|
'period' => $metric['period'],
|
||||||
|
'calls_used' => $metric['used'],
|
||||||
|
'quota_calls' => $metric['limit'],
|
||||||
|
'calls_remaining' => $metric['remaining'],
|
||||||
|
'usage_percent' => $metric['usage_percent'],
|
||||||
|
'status' => $metric['status'],
|
||||||
|
'source' => $metric['source'],
|
||||||
|
'enforce_mode' => $metric['enforce_mode'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentMetric(array $descriptor, ?string $date = null): array
|
||||||
|
{
|
||||||
|
$descriptor = $this->normalizeDescriptor($descriptor);
|
||||||
|
$setting = $this->settingFor($descriptor);
|
||||||
|
$window = $this->periodWindow((string)$descriptor['period'], $date);
|
||||||
|
$limit = $this->resolveLimitQuantity($descriptor);
|
||||||
|
$used = null;
|
||||||
|
$updatedAt = null;
|
||||||
|
$historyAvailable = false;
|
||||||
|
$snapshotExtra = [];
|
||||||
|
|
||||||
|
if (($descriptor['source'] ?? '') === 'provider_snapshot') {
|
||||||
|
$snapshot = $this->latestProviderSnapshot($descriptor);
|
||||||
|
if ($snapshot !== null) {
|
||||||
|
$used = $snapshot['used_quantity'];
|
||||||
|
$limit = $snapshot['limit_quantity'];
|
||||||
|
$updatedAt = $snapshot['checked_at'];
|
||||||
|
$snapshotExtra = $snapshot['extra'];
|
||||||
|
$historyAvailable = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$counter = $this->counterFor($descriptor, $window);
|
||||||
|
if ($counter !== null) {
|
||||||
|
$used = $counter['used_quantity'];
|
||||||
|
$limit = $counter['limit_quantity'] ?? $limit;
|
||||||
|
$updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null;
|
||||||
|
$historyAvailable = true;
|
||||||
|
} else {
|
||||||
|
$derived = $this->derivedOrLegacyUsage($descriptor, $window);
|
||||||
|
if ($derived !== null) {
|
||||||
|
$used = $derived;
|
||||||
|
$historyAvailable = true;
|
||||||
|
} elseif (($descriptor['source'] ?? '') === 'internal_counter') {
|
||||||
|
$used = 0.0;
|
||||||
|
$historyAvailable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->statusForUsage($used, $limit, $setting);
|
||||||
|
$remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null;
|
||||||
|
$usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null;
|
||||||
|
|
||||||
|
return array_merge([
|
||||||
|
'module_key' => $descriptor['module_key'],
|
||||||
|
'module_label' => $descriptor['module_label'],
|
||||||
|
'metric_key' => $descriptor['metric_key'],
|
||||||
|
'metric_label' => $descriptor['metric_label'],
|
||||||
|
'unit' => $descriptor['unit'],
|
||||||
|
'period' => $descriptor['period'],
|
||||||
|
'scope_type' => $descriptor['scope_type'],
|
||||||
|
'scope_id' => $descriptor['scope_id'],
|
||||||
|
'source' => $descriptor['source'],
|
||||||
|
'primary' => (bool)$descriptor['primary'],
|
||||||
|
'writable_limit' => (bool)$descriptor['writable_limit'],
|
||||||
|
'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null),
|
||||||
|
'config_module' => $descriptor['config_module'] ?? null,
|
||||||
|
'config_variable' => $descriptor['config_variable'] ?? null,
|
||||||
|
'used' => $used,
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
'usage_percent' => $usagePercent,
|
||||||
|
'status' => $status,
|
||||||
|
'enabled' => (bool)$setting['enabled'],
|
||||||
|
'enforce_mode' => $setting['enforce_mode'],
|
||||||
|
'soft_limit_percent' => (float)$setting['soft_limit_percent'],
|
||||||
|
'window' => [
|
||||||
|
'start' => $window['start_c'],
|
||||||
|
'end' => $window['end_c'],
|
||||||
|
'timezone' => date_default_timezone_get(),
|
||||||
|
],
|
||||||
|
'updated_at' => $updatedAt,
|
||||||
|
'history_available' => $historyAvailable,
|
||||||
|
], $snapshotExtra);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireDescriptor(string $moduleKey, string $metricKey): array
|
||||||
|
{
|
||||||
|
$descriptor = $this->registry->find($moduleKey, $metricKey);
|
||||||
|
if ($descriptor === null) {
|
||||||
|
throw new Exception('Unknown module usage metric.');
|
||||||
|
}
|
||||||
|
return $descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDescriptor(array $descriptor): array
|
||||||
|
{
|
||||||
|
$found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']);
|
||||||
|
return $found ?? $descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function settingFor(array $descriptor): array
|
||||||
|
{
|
||||||
|
$default = [
|
||||||
|
'enabled' => true,
|
||||||
|
'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'),
|
||||||
|
'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT enabled, enforce_mode, soft_limit_percent
|
||||||
|
FROM module_quota_settings
|
||||||
|
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
|
||||||
|
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'enabled' => (bool)((int)($row['enabled'] ?? 1)),
|
||||||
|
'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true)
|
||||||
|
? (string)$row['enforce_mode']
|
||||||
|
: $default['enforce_mode'],
|
||||||
|
'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null)
|
||||||
|
? (float)$row['soft_limit_percent']
|
||||||
|
: $default['soft_limit_percent'],
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLimitQuantity(array $descriptor): ?float
|
||||||
|
{
|
||||||
|
if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT value
|
||||||
|
FROM module_config
|
||||||
|
WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . "
|
||||||
|
AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . "
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
if (!is_array($row) || !is_numeric($row['value'] ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return max(0.0, (float)$row['value']);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeConfigLimit(array $descriptor, int $limit): void
|
||||||
|
{
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$module = (string)$descriptor['config_module'];
|
||||||
|
$variable = (string)$descriptor['config_variable'];
|
||||||
|
$type = (string)($descriptor['config_type'] ?? 'int');
|
||||||
|
|
||||||
|
$existing = $db->query(
|
||||||
|
"SELECT id
|
||||||
|
FROM module_config
|
||||||
|
WHERE module = " . $this->sqlString($module) . "
|
||||||
|
AND variable = " . $this->sqlString($variable) . "
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
if ($existing instanceof mysqli_result && $existing->num_rows > 0) {
|
||||||
|
$db->query(
|
||||||
|
"UPDATE module_config
|
||||||
|
SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . "
|
||||||
|
WHERE module = " . $this->sqlString($module) . "
|
||||||
|
AND variable = " . $this->sqlString($variable)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO module_config (module, variable, value, type)
|
||||||
|
VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
system_search_cache::markDirtyTable('module_config');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void
|
||||||
|
{
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$baseline = $this->derivedOrLegacyUsage($descriptor, $period);
|
||||||
|
$baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline);
|
||||||
|
$metadata = [
|
||||||
|
'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter',
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"INSERT IGNORE INTO module_usage_counters
|
||||||
|
(module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json)
|
||||||
|
VALUES (
|
||||||
|
" . $this->sqlString((string)$descriptor['module_key']) . ",
|
||||||
|
" . $this->sqlString((string)$descriptor['metric_key']) . ",
|
||||||
|
" . $this->sqlString((string)$descriptor['scope_type']) . ",
|
||||||
|
" . $this->sqlString((string)$descriptor['scope_id']) . ",
|
||||||
|
" . $this->sqlString($period['key']) . ",
|
||||||
|
" . $this->sqlString($period['start_sql']) . ",
|
||||||
|
" . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ",
|
||||||
|
" . $this->sqlString((string)$descriptor['unit']) . ",
|
||||||
|
" . $this->numberSql($baseline) . ",
|
||||||
|
" . $this->nullableNumberSql($limit) . ",
|
||||||
|
" . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ",
|
||||||
|
" . $this->jsonSql($metadata) . "
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function counterFor(array $descriptor, array $period): ?array
|
||||||
|
{
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT used_quantity, limit_quantity, status, created_at, updated_at
|
||||||
|
FROM module_usage_counters
|
||||||
|
WHERE " . $this->counterWhereSql($descriptor, $period) . "
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'used_quantity' => (float)$row['used_quantity'],
|
||||||
|
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
|
||||||
|
'status' => (string)$row['status'],
|
||||||
|
'created_at' => $row['created_at'] ?? null,
|
||||||
|
'updated_at' => $row['updated_at'] ?? null,
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestProviderSnapshot(array $descriptor): ?array
|
||||||
|
{
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at
|
||||||
|
FROM module_usage_snapshots
|
||||||
|
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
|
||||||
|
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
|
||||||
|
ORDER BY checked_at DESC, id DESC
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = json_decode((string)($row['raw_payload_json'] ?? ''), true);
|
||||||
|
$usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : [];
|
||||||
|
$extra = [];
|
||||||
|
if (isset($usage['version'])) {
|
||||||
|
$extra['version'] = (string)$usage['version'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('usage_available', $usage)) {
|
||||||
|
$extra['usage_available'] = $this->toBool($usage['usage_available']);
|
||||||
|
}
|
||||||
|
if (isset($usage['unavailable_reason'])) {
|
||||||
|
$extra['unavailable_reason'] = (string)$usage['unavailable_reason'];
|
||||||
|
}
|
||||||
|
if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) {
|
||||||
|
$extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'],
|
||||||
|
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
|
||||||
|
'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'],
|
||||||
|
'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'],
|
||||||
|
'status' => (string)$row['status'],
|
||||||
|
'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null,
|
||||||
|
'extra' => $extra,
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'module_key' => $descriptor['module_key'],
|
||||||
|
'module_label' => $descriptor['module_label'],
|
||||||
|
'metric_key' => $descriptor['metric_key'],
|
||||||
|
'metric_label' => $descriptor['metric_label'],
|
||||||
|
'unit' => $descriptor['unit'],
|
||||||
|
'period' => $descriptor['period'],
|
||||||
|
'scope_type' => $descriptor['scope_type'],
|
||||||
|
'scope_id' => $descriptor['scope_id'],
|
||||||
|
'source' => $descriptor['source'],
|
||||||
|
'primary' => (bool)$descriptor['primary'],
|
||||||
|
'writable_limit' => false,
|
||||||
|
'limit_source' => 'provider',
|
||||||
|
'used' => $used,
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
'usage_percent' => $percent,
|
||||||
|
'status' => $status,
|
||||||
|
'enabled' => true,
|
||||||
|
'enforce_mode' => 'observe',
|
||||||
|
'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT,
|
||||||
|
'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()],
|
||||||
|
'updated_at' => $checkedAt,
|
||||||
|
'history_available' => true,
|
||||||
|
'version' => isset($usage['version']) ? (string)$usage['version'] : null,
|
||||||
|
'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true,
|
||||||
|
'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null,
|
||||||
|
'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys'])
|
||||||
|
? array_values(array_map('strval', $usage['detected_keys']))
|
||||||
|
: [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function derivedOrLegacyUsage(array $descriptor, array $period): ?float
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (isset($descriptor['legacy_count_table'])) {
|
||||||
|
return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($descriptor['legacy_log_module'])) {
|
||||||
|
return $this->countActionLogs(
|
||||||
|
(string)$descriptor['legacy_log_module'],
|
||||||
|
isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null,
|
||||||
|
$period
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) {
|
||||||
|
'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period),
|
||||||
|
'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'),
|
||||||
|
'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period),
|
||||||
|
'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period),
|
||||||
|
'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period),
|
||||||
|
'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period),
|
||||||
|
'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period),
|
||||||
|
'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period),
|
||||||
|
'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period),
|
||||||
|
'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countActionLogs(string $module, ?string $action, array $period): ?float
|
||||||
|
{
|
||||||
|
if (!$this->tableExists('module_usage_logs')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$where = "UPPER(module) = " . $this->sqlString(strtoupper($module));
|
||||||
|
if ($action !== null && $action !== '') {
|
||||||
|
$where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action));
|
||||||
|
}
|
||||||
|
$where .= $this->periodWhereSql('created_at', $period);
|
||||||
|
|
||||||
|
$result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}");
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
return is_array($row) ? (float)$row['usage_count'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float
|
||||||
|
{
|
||||||
|
if (!$this->tableExists($table)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$where = '1=1';
|
||||||
|
if ($dateColumn !== null) {
|
||||||
|
$where .= $this->periodWhereSql($dateColumn, $period);
|
||||||
|
} elseif ($period['key'] !== 'all_time') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}");
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
return is_array($row) ? (float)$row['usage_count'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sumColumn(string $table, string $column): ?float
|
||||||
|
{
|
||||||
|
if (!$this->tableExists($table) || !$this->columnExists($table, $column)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`");
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
return is_array($row) ? (float)$row['usage_sum'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function historyForModule(string $moduleKey, array $filters): array
|
||||||
|
{
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100;
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at
|
||||||
|
FROM module_usage_counters
|
||||||
|
WHERE module_key = " . $this->sqlString($moduleKey) . "
|
||||||
|
ORDER BY period_start DESC, id DESC
|
||||||
|
LIMIT {$limit}"
|
||||||
|
);
|
||||||
|
if ($result instanceof mysqli_result) {
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusForUsage(?float $used, ?float $limit, array $setting): string
|
||||||
|
{
|
||||||
|
if (($setting['enabled'] ?? true) !== true) {
|
||||||
|
return 'disabled';
|
||||||
|
}
|
||||||
|
if ($used === null) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
if ($limit === null) {
|
||||||
|
return 'unlimited';
|
||||||
|
}
|
||||||
|
if ($limit <= 0.0) {
|
||||||
|
return $used > 0.0 ? 'exhausted' : 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
$percent = ($used / $limit) * 100;
|
||||||
|
if ($used >= $limit || $percent >= 100.0) {
|
||||||
|
return 'exhausted';
|
||||||
|
}
|
||||||
|
if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) {
|
||||||
|
return 'near_limit';
|
||||||
|
}
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function worseStatus(string $current, string $candidate): string
|
||||||
|
{
|
||||||
|
return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusRank(string $status): int
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'exhausted' => 5,
|
||||||
|
'near_limit' => 4,
|
||||||
|
'unknown' => 3,
|
||||||
|
'disabled' => 2,
|
||||||
|
'unlimited' => 1,
|
||||||
|
'ok' => 0,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quotaExceededMessage(array $descriptor): string
|
||||||
|
{
|
||||||
|
return match ((string)$descriptor['period']) {
|
||||||
|
'day' => 'Daily limit exceeded',
|
||||||
|
'month' => 'Monthly limit exceeded',
|
||||||
|
default => 'Quota limit exceeded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function periodWindow(string $period, ?string $date = null): array
|
||||||
|
{
|
||||||
|
$timestamp = $date ? strtotime($date) : time();
|
||||||
|
if ($timestamp === false) {
|
||||||
|
$timestamp = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($period) {
|
||||||
|
'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')),
|
||||||
|
'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')),
|
||||||
|
'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null],
|
||||||
|
default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function periodFromTimestamps(string $key, int $start, int $end): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => $key,
|
||||||
|
'start_sql' => date('Y-m-d H:i:s', $start),
|
||||||
|
'end_sql' => date('Y-m-d H:i:s', $end),
|
||||||
|
'start_c' => date('c', $start),
|
||||||
|
'end_c' => date('c', $end),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function periodWhereSql(string $dateColumn, array $period): string
|
||||||
|
{
|
||||||
|
if ($period['key'] === 'all_time' || $period['key'] === 'provider') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn);
|
||||||
|
if ($dateColumn === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function counterWhereSql(array $descriptor, array $period): string
|
||||||
|
{
|
||||||
|
return "module_key = " . $this->sqlString((string)$descriptor['module_key'])
|
||||||
|
. " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key'])
|
||||||
|
. " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type'])
|
||||||
|
. " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id'])
|
||||||
|
. " AND period_key = " . $this->sqlString($period['key'])
|
||||||
|
. " AND period_start = " . $this->sqlString($period['start_sql']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function databaseReady(): bool
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tableExists(string $table): bool
|
||||||
|
{
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
if ($table === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table));
|
||||||
|
return $result instanceof mysqli_result && $result->num_rows > 0;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnExists(string $table, string $column): bool
|
||||||
|
{
|
||||||
|
if (!$this->databaseReady()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $db;
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||||
|
if ($table === '' || $column === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column));
|
||||||
|
return $result instanceof mysqli_result && $result->num_rows > 0;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function firstNumeric(array $payload, array $keys): ?float
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (isset($payload[$key]) && is_numeric($payload[$key])) {
|
||||||
|
return (float)$payload[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redactPayload(array $payload): array
|
||||||
|
{
|
||||||
|
$redacted = [];
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
$normalized = strtolower((string)$key);
|
||||||
|
if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) {
|
||||||
|
$redacted[$key] = '[redacted]';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value;
|
||||||
|
}
|
||||||
|
return $redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toBool(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sqlString(string $value): string
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
return "'" . $db->escape_string($value) . "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function numberSql(float $value): string
|
||||||
|
{
|
||||||
|
return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableNumberSql(?float $value): string
|
||||||
|
{
|
||||||
|
return $value === null ? 'NULL' : $this->numberSql($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jsonSql(array $value): string
|
||||||
|
{
|
||||||
|
return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a;
|
|||||||
use motorapi\helpers\motorapi_vehicle_types;
|
use motorapi\helpers\motorapi_vehicle_types;
|
||||||
use motorapi\motorapi_c;
|
use motorapi\motorapi_c;
|
||||||
use objects\motorapi_lookups_o;
|
use objects\motorapi_lookups_o;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class motorapi implements motorapi_i
|
class motorapi implements motorapi_i
|
||||||
{
|
{
|
||||||
@@ -182,10 +183,10 @@ class motorapi implements motorapi_i
|
|||||||
self::requireModuleEnabled();
|
self::requireModuleEnabled();
|
||||||
// Validate the license plate
|
// Validate the license plate
|
||||||
self::requireValidLicensePlate($licensePlate);
|
self::requireValidLicensePlate($licensePlate);
|
||||||
// Validate the daily limit
|
|
||||||
self::requireDailyLimitNotExceeded();
|
|
||||||
// Validate the secret key
|
// Validate the secret key
|
||||||
self::requireValidSecretKey();
|
self::requireValidSecretKey();
|
||||||
|
// Reserve quota for the outbound provider call. Cache hits return before this point.
|
||||||
|
$this->reserveLookupQuota($licensePlate, $endpoint, $method);
|
||||||
// Send the request
|
// Send the request
|
||||||
$response = match ($method) {
|
$response = match ($method) {
|
||||||
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
|
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
|
||||||
@@ -217,7 +218,7 @@ class motorapi implements motorapi_i
|
|||||||
function requireDailyLimitNotExceeded(): void
|
function requireDailyLimitNotExceeded(): void
|
||||||
{
|
{
|
||||||
// Check if the daily limit is exceeded
|
// Check if the daily limit is exceeded
|
||||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||||
throw new Exception('Daily limit exceeded');
|
throw new Exception('Daily limit exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,12 +228,29 @@ class motorapi implements motorapi_i
|
|||||||
*/
|
*/
|
||||||
function getDailyRequestCounter(): int
|
function getDailyRequestCounter(): int
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
|
return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls');
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
// Count the rows from the motorapi request log that was made today
|
// Count the rows from the motorapi request log that was made today
|
||||||
$motorapi_lookups = new motorapi_lookups_o();
|
$motorapi_lookups = new motorapi_lookups_o();
|
||||||
$motorapi_lookups->getTodayCount();
|
$motorapi_lookups->getTodayCount();
|
||||||
return $motorapi_lookups->getTodayCount();
|
return $motorapi_lookups->getTodayCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void
|
||||||
|
{
|
||||||
|
(new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [
|
||||||
|
'license_plate' => $licensePlate,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class pdf_store implements minio_pdfs_i
|
|||||||
|
|
||||||
public function isFileInStore(string $file): bool
|
public function isFileInStore(string $file): bool
|
||||||
{
|
{
|
||||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
return self::doesObjectExist($file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,4 +76,4 @@ class pdf_store implements minio_pdfs_i
|
|||||||
$file_path
|
$file_path
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class security_schema_bootstrap
|
||||||
|
{
|
||||||
|
private static bool $initialized = false;
|
||||||
|
|
||||||
|
public static function ensureTables(): void
|
||||||
|
{
|
||||||
|
if (self::$initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$queries = [
|
||||||
|
"CREATE TABLE IF NOT EXISTS security_firewall_rules (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
action VARCHAR(16) NOT NULL,
|
||||||
|
target_type VARCHAR(32) NOT NULL,
|
||||||
|
target_value VARCHAR(255) NOT NULL,
|
||||||
|
route_pattern VARCHAR(255) NULL,
|
||||||
|
priority INT NOT NULL DEFAULT 100,
|
||||||
|
reason TEXT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
expires_at DATETIME NULL,
|
||||||
|
metadata_json LONGTEXT NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
INDEX idx_security_firewall_rules_active (enabled, deleted_at, expires_at),
|
||||||
|
INDEX idx_security_firewall_rules_target (target_type, target_value),
|
||||||
|
INDEX idx_security_firewall_rules_priority (priority)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS security_policy_rules (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rule_key VARCHAR(64) NOT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
threshold_count INT NOT NULL,
|
||||||
|
window_seconds INT NOT NULL,
|
||||||
|
mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||||
|
exempt_permission_nodes_json LONGTEXT NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_security_policy_rules_key (rule_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS security_policy_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rule_key VARCHAR(64) NOT NULL,
|
||||||
|
subject_type VARCHAR(32) NOT NULL,
|
||||||
|
subject_key VARCHAR(191) NOT NULL,
|
||||||
|
route_path VARCHAR(255) NULL,
|
||||||
|
route_template VARCHAR(255) NULL,
|
||||||
|
method VARCHAR(16) NULL,
|
||||||
|
source_ip VARCHAR(64) NULL,
|
||||||
|
customer_number INT NULL,
|
||||||
|
user_id INT NULL,
|
||||||
|
subuser_id INT NULL,
|
||||||
|
metadata_json LONGTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_security_policy_events_window (rule_key, subject_type, subject_key, created_at),
|
||||||
|
INDEX idx_security_policy_events_created (created_at),
|
||||||
|
INDEX idx_security_policy_events_customer (customer_number, created_at),
|
||||||
|
INDEX idx_security_policy_events_ip (source_ip, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS security_incidents (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
incident_key VARCHAR(191) NOT NULL,
|
||||||
|
type VARCHAR(64) NOT NULL,
|
||||||
|
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
source_ip VARCHAR(64) NULL,
|
||||||
|
customer_number INT NULL,
|
||||||
|
user_id INT NULL,
|
||||||
|
subuser_id INT NULL,
|
||||||
|
route_path VARCHAR(255) NULL,
|
||||||
|
route_template VARCHAR(255) NULL,
|
||||||
|
method VARCHAR(16) NULL,
|
||||||
|
related_rule_id BIGINT UNSIGNED NULL,
|
||||||
|
related_firewall_rule_id BIGINT UNSIGNED NULL,
|
||||||
|
occurrence_count INT NOT NULL DEFAULT 1,
|
||||||
|
metadata_json LONGTEXT NULL,
|
||||||
|
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
resolved_by INT NULL,
|
||||||
|
resolved_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_security_incidents_key (incident_key),
|
||||||
|
INDEX idx_security_incidents_status_seen (status, last_seen_at),
|
||||||
|
INDEX idx_security_incidents_type_seen (type, last_seen_at),
|
||||||
|
INDEX idx_security_incidents_customer_seen (customer_number, last_seen_at),
|
||||||
|
INDEX idx_security_incidents_ip_seen (source_ip, last_seen_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS security_incident_notes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
incident_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_security_incident_notes_incident (incident_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($queries as $query) {
|
||||||
|
$db->query($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tablesExist(): bool
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$database = $db->escape_string($db->getDatabase());
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT COUNT(*) AS count
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = '{$database}'
|
||||||
|
AND table_name IN (
|
||||||
|
'security_firewall_rules',
|
||||||
|
'security_policy_rules',
|
||||||
|
'security_policy_events',
|
||||||
|
'security_incidents',
|
||||||
|
'security_incident_notes'
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||||
|
return (int)($row['count'] ?? 0) === 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ class selfserve_schema_bootstrap
|
|||||||
department_id INT NOT NULL,
|
department_id INT NOT NULL,
|
||||||
machine_type_id INT NULL,
|
machine_type_id INT NULL,
|
||||||
customer_number INT NULL,
|
customer_number INT NULL,
|
||||||
|
subuser_id INT NULL,
|
||||||
vehicle_id INT NULL,
|
vehicle_id INT NULL,
|
||||||
vehicle_type_id INT NULL,
|
vehicle_type_id INT NULL,
|
||||||
reg VARCHAR(255) NOT NULL,
|
reg VARCHAR(255) NOT NULL,
|
||||||
@@ -73,7 +74,11 @@ class selfserve_schema_bootstrap
|
|||||||
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
|
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
|
||||||
INDEX idx_selfserve_wash_sessions_status (status),
|
INDEX idx_selfserve_wash_sessions_status (status),
|
||||||
INDEX idx_selfserve_wash_sessions_customer (customer_number),
|
INDEX idx_selfserve_wash_sessions_customer (customer_number),
|
||||||
INDEX idx_selfserve_wash_sessions_created_at (created_at)
|
INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at),
|
||||||
|
INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_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",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
|
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
|
||||||
@@ -214,11 +219,46 @@ class selfserve_schema_bootstrap
|
|||||||
'dynamic_images_vehicle_type',
|
'dynamic_images_vehicle_type',
|
||||||
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
|
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
|
||||||
);
|
);
|
||||||
|
self::ensureColumn(
|
||||||
|
'selfserve_wash_sessions',
|
||||||
|
'customer_number',
|
||||||
|
'ALTER TABLE selfserve_wash_sessions ADD COLUMN customer_number INT NULL AFTER department_id'
|
||||||
|
);
|
||||||
|
self::ensureColumn(
|
||||||
|
'selfserve_wash_sessions',
|
||||||
|
'subuser_id',
|
||||||
|
'ALTER TABLE selfserve_wash_sessions ADD COLUMN subuser_id INT NULL AFTER customer_number'
|
||||||
|
);
|
||||||
self::ensureColumn(
|
self::ensureColumn(
|
||||||
'selfserve_wash_sessions',
|
'selfserve_wash_sessions',
|
||||||
'wash_started_at',
|
'wash_started_at',
|
||||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_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_customer',
|
||||||
|
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer (customer_number)'
|
||||||
|
);
|
||||||
|
self::ensureIndex(
|
||||||
|
'selfserve_wash_sessions',
|
||||||
|
'idx_selfserve_wash_sessions_subuser_active',
|
||||||
|
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at)'
|
||||||
|
);
|
||||||
|
self::ensureIndex(
|
||||||
|
'selfserve_wash_sessions',
|
||||||
|
'idx_selfserve_wash_sessions_customer_subuser_active',
|
||||||
|
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_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;
|
self::$initialized = true;
|
||||||
}
|
}
|
||||||
@@ -252,6 +292,35 @@ class selfserve_schema_bootstrap
|
|||||||
$db->query($alterSql);
|
$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
|
* @param array<int,string> $acceptedDataTypes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use objects\logs_o;
|
||||||
|
use objects\subusers_o;
|
||||||
|
|
||||||
|
class subuser_contact_verification_service
|
||||||
|
{
|
||||||
|
public const CHANNEL_EMAIL = 'email';
|
||||||
|
public const CHANNEL_PHONE = 'phone';
|
||||||
|
public const CODE_TTL_SECONDS = 600;
|
||||||
|
public const RESEND_COOLDOWN_SECONDS = 60;
|
||||||
|
public const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
private ?object $redis;
|
||||||
|
private $codeGenerator;
|
||||||
|
private $timeProvider;
|
||||||
|
private $smsSender;
|
||||||
|
private $emailSender;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
?object $redis = null,
|
||||||
|
?callable $codeGenerator = null,
|
||||||
|
?callable $timeProvider = null,
|
||||||
|
?callable $smsSender = null,
|
||||||
|
?callable $emailSender = null
|
||||||
|
) {
|
||||||
|
$this->redis = $redis ?? (defined('redis') ? constant('redis') : null);
|
||||||
|
$this->codeGenerator = $codeGenerator;
|
||||||
|
$this->timeProvider = $timeProvider;
|
||||||
|
$this->smsSender = $smsSender;
|
||||||
|
$this->emailSender = $emailSender;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{state:string,email:array<string,mixed>,phone:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
public function status(subusers_o $subuser): array
|
||||||
|
{
|
||||||
|
$email = $this->emailDestination($subuser);
|
||||||
|
$phone = $this->phoneDestination($subuser);
|
||||||
|
$emailVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_EMAIL);
|
||||||
|
$phoneVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_PHONE);
|
||||||
|
|
||||||
|
$emailStatus = [
|
||||||
|
'channel' => self::CHANNEL_EMAIL,
|
||||||
|
'value' => $email,
|
||||||
|
'masked_value' => $email !== null ? $this->maskEmail($email) : null,
|
||||||
|
'available' => $email !== null,
|
||||||
|
'verified' => $email !== null && $emailVerifiedAt !== null,
|
||||||
|
'verified_at' => $emailVerifiedAt,
|
||||||
|
];
|
||||||
|
$phoneStatus = [
|
||||||
|
'channel' => self::CHANNEL_PHONE,
|
||||||
|
'country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
|
||||||
|
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
||||||
|
'value' => $phone,
|
||||||
|
'masked_value' => $phone !== null ? $this->maskPhone($phone) : null,
|
||||||
|
'available' => $phone !== null,
|
||||||
|
'verified' => $phone !== null && $phoneVerifiedAt !== null,
|
||||||
|
'verified_at' => $phoneVerifiedAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => $this->verificationState($emailStatus, $phoneStatus),
|
||||||
|
'email' => $emailStatus,
|
||||||
|
'phone' => $phoneStatus,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function sendCode(subusers_o $subuser, string $channel, array $actor = []): array
|
||||||
|
{
|
||||||
|
$channel = $this->normalizeChannel($channel);
|
||||||
|
$destination = $this->destinationFor($subuser, $channel);
|
||||||
|
if ($destination === null) {
|
||||||
|
return $this->delivery($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->redis === null) {
|
||||||
|
return $this->delivery($channel, 'unavailable', 'Verification delivery is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = $this->now();
|
||||||
|
$existing = $this->readChallenge($subuser, $channel);
|
||||||
|
if ($existing !== null) {
|
||||||
|
$sentAt = (int)($existing['sent_at'] ?? 0);
|
||||||
|
$retryAfter = self::RESEND_COOLDOWN_SECONDS - ($now - $sentAt);
|
||||||
|
if ($retryAfter > 0) {
|
||||||
|
return $this->delivery($channel, 'throttled', 'Please wait before requesting another code.', [
|
||||||
|
'retry_after' => $retryAfter,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $this->generateCode();
|
||||||
|
$destinationKey = $this->destinationKey($subuser, $channel);
|
||||||
|
$nonce = bin2hex(random_bytes(16));
|
||||||
|
$payload = [
|
||||||
|
'hash' => $this->hashCode($code, $destinationKey, $nonce),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'destination' => $destinationKey,
|
||||||
|
'attempts' => 0,
|
||||||
|
'sent_at' => $now,
|
||||||
|
'expires_at' => $now + self::CODE_TTL_SECONDS,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->deliverCode($subuser, $channel, $destination, $code);
|
||||||
|
$this->writeChallenge($subuser, $channel, $payload);
|
||||||
|
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_SENT', $subuser, $channel, $actor);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_FAILED', $subuser, $channel, $actor);
|
||||||
|
return $this->delivery($channel, 'failed', 'Verification delivery failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->delivery($channel, 'sent', 'Verification code sent.', [
|
||||||
|
'masked_destination' => $channel === self::CHANNEL_EMAIL
|
||||||
|
? $this->maskEmail($destination)
|
||||||
|
: $this->maskPhone($destination),
|
||||||
|
'expires_in' => self::CODE_TTL_SECONDS,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function verifyCode(subusers_o $subuser, string $channel, string $code, array $actor = []): array
|
||||||
|
{
|
||||||
|
$channel = $this->normalizeChannel($channel);
|
||||||
|
$code = trim($code);
|
||||||
|
if (!preg_match('/^[0-9]{6}$/', $code)) {
|
||||||
|
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$challenge = $this->readChallenge($subuser, $channel);
|
||||||
|
if ($challenge === null || (int)($challenge['expires_at'] ?? 0) < $this->now()) {
|
||||||
|
$this->deleteChallenge($subuser, $channel);
|
||||||
|
return $this->verificationResult($channel, 'expired', 'Verification code expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($challenge['destination'] ?? null) !== $this->destinationKey($subuser, $channel)) {
|
||||||
|
$this->deleteChallenge($subuser, $channel);
|
||||||
|
return $this->verificationResult($channel, 'destination_changed', 'Contact value changed. Request a new code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempts = (int)($challenge['attempts'] ?? 0);
|
||||||
|
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||||
|
$this->deleteChallenge($subuser, $channel);
|
||||||
|
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedHash = (string)($challenge['hash'] ?? '');
|
||||||
|
$nonce = (string)($challenge['nonce'] ?? '');
|
||||||
|
if (!hash_equals($expectedHash, $this->hashCode($code, (string)$challenge['destination'], $nonce))) {
|
||||||
|
$nextAttempts = $attempts + 1;
|
||||||
|
if ($nextAttempts >= self::MAX_ATTEMPTS) {
|
||||||
|
$this->deleteChallenge($subuser, $channel);
|
||||||
|
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||||
|
}
|
||||||
|
$challenge['attempts'] = $nextAttempts;
|
||||||
|
$remainingTtl = max(1, (int)($challenge['expires_at'] ?? $this->now()) - $this->now());
|
||||||
|
$this->writeChallenge($subuser, $channel, $challenge, $remainingTtl);
|
||||||
|
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->markVerified($subuser, $channel);
|
||||||
|
$this->deleteChallenge($subuser, $channel);
|
||||||
|
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_VERIFIED', $subuser, $channel, $actor);
|
||||||
|
|
||||||
|
return $this->verificationResult($channel, 'verified', 'Contact value verified.', [
|
||||||
|
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function setVerificationState(subusers_o $subuser, string $channel, bool $verified, array $actor = []): array
|
||||||
|
{
|
||||||
|
$channel = $this->normalizeChannel($channel);
|
||||||
|
if ($verified && $this->destinationFor($subuser, $channel) === null) {
|
||||||
|
return $this->verificationResult($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verified) {
|
||||||
|
$this->markVerified($subuser, $channel);
|
||||||
|
} else {
|
||||||
|
$this->markUnverified($subuser, $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->deleteChallenge($subuser, $channel);
|
||||||
|
$this->logEvent(
|
||||||
|
$verified
|
||||||
|
? 'SUBUSER_CONTACT_VERIFICATION_MARKED_VERIFIED'
|
||||||
|
: 'SUBUSER_CONTACT_VERIFICATION_MARKED_UNVERIFIED',
|
||||||
|
$subuser,
|
||||||
|
$channel,
|
||||||
|
$actor
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->verificationResult($channel, $verified ? 'verified' : 'unverified', 'Contact verification state updated.', [
|
||||||
|
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $updates
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function clearVerificationForChangedContacts(subusers_o $subuser, array $updates): array
|
||||||
|
{
|
||||||
|
if (array_key_exists('email', $updates)) {
|
||||||
|
$current = $this->normalizeNullableString($subuser->email->value());
|
||||||
|
$next = $this->normalizeNullableString($updates['email']);
|
||||||
|
if ($current !== $next) {
|
||||||
|
$updates['email_verified_at'] = null;
|
||||||
|
$this->deleteChallenge($subuser, self::CHANNEL_EMAIL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$phoneChanged = false;
|
||||||
|
if (array_key_exists('phone_country_code', $updates)) {
|
||||||
|
$phoneChanged = (int)$subuser->phone_country_code->value() !== (int)$updates['phone_country_code'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('phone', $updates)) {
|
||||||
|
$phoneChanged = $phoneChanged || (int)$subuser->phone->value() !== (int)$updates['phone'];
|
||||||
|
}
|
||||||
|
if ($phoneChanged) {
|
||||||
|
$updates['phone_verified_at'] = null;
|
||||||
|
$this->deleteChallenge($subuser, self::CHANNEL_PHONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeChannel(string $channel): string
|
||||||
|
{
|
||||||
|
$channel = strtolower(trim($channel));
|
||||||
|
if (!in_array($channel, [self::CHANNEL_EMAIL, self::CHANNEL_PHONE], true)) {
|
||||||
|
throw new Exception('Invalid verification channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function now(): int
|
||||||
|
{
|
||||||
|
if ($this->timeProvider !== null) {
|
||||||
|
return (int)call_user_func($this->timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCode(): string
|
||||||
|
{
|
||||||
|
if ($this->codeGenerator !== null) {
|
||||||
|
$code = (string)call_user_func($this->codeGenerator);
|
||||||
|
if (preg_match('/^[0-9]{6}$/', $code)) {
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string)random_int(100000, 999999);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hashCode(string $code, string $destination, string $nonce): string
|
||||||
|
{
|
||||||
|
$secret = (string)(getenv('APP_KEY') ?: getenv('JWT_SECRET') ?: __FILE__);
|
||||||
|
return hash('sha256', $secret . ':' . $nonce . ':' . $destination . ':' . $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function destinationFor(subusers_o $subuser, string $channel): ?string
|
||||||
|
{
|
||||||
|
return $channel === self::CHANNEL_EMAIL
|
||||||
|
? $this->emailDestination($subuser)
|
||||||
|
: $this->phoneDestination($subuser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emailDestination(subusers_o $subuser): ?string
|
||||||
|
{
|
||||||
|
$email = $this->normalizeNullableString($subuser->email->value());
|
||||||
|
return $email !== null && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function phoneDestination(subusers_o $subuser): ?string
|
||||||
|
{
|
||||||
|
$countryCode = $subuser->phone_country_code->value();
|
||||||
|
$phone = $subuser->phone->value();
|
||||||
|
if ($countryCode === null || $phone === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = preg_replace('/[^0-9]/', '', (string)$countryCode . (string)$phone);
|
||||||
|
return $normalized !== '' ? $normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function destinationKey(subusers_o $subuser, string $channel): string
|
||||||
|
{
|
||||||
|
return $channel . ':' . (string)($this->destinationFor($subuser, $channel) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function key(subusers_o $subuser, string $channel): string
|
||||||
|
{
|
||||||
|
return 'subuser_contact_verification:' . (int)$subuser->id . ':' . $channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readChallenge(subusers_o $subuser, string $channel): ?array
|
||||||
|
{
|
||||||
|
if ($this->redis === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $this->redis->get($this->key($subuser, $channel));
|
||||||
|
if (!is_string($raw) || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeChallenge(subusers_o $subuser, string $channel, array $payload, ?int $ttl = null): void
|
||||||
|
{
|
||||||
|
if ($this->redis === null) {
|
||||||
|
throw new Exception('Verification cache is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
throw new Exception('Failed to encode verification challenge');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redis->setEx($this->key($subuser, $channel), $encoded, $ttl ?? self::CODE_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteChallenge(subusers_o $subuser, string $channel): void
|
||||||
|
{
|
||||||
|
if ($this->redis !== null) {
|
||||||
|
$this->redis->delete($this->key($subuser, $channel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deliverCode(subusers_o $subuser, string $channel, string $destination, string $code): void
|
||||||
|
{
|
||||||
|
if ($channel === self::CHANNEL_PHONE) {
|
||||||
|
if ($this->smsSender !== null) {
|
||||||
|
call_user_func($this->smsSender, $destination, $code, $subuser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateway = new gatewayapi();
|
||||||
|
if (!$gateway->isEnabled()) {
|
||||||
|
throw new Exception('SMS delivery is not configured.');
|
||||||
|
}
|
||||||
|
$gateway->send([$destination], 'Truck Wash verifikationskode: ' . $code . '. Den udløber om 10 minutter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->emailSender !== null) {
|
||||||
|
call_user_func($this->emailSender, $destination, $code, $subuser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipientName = $this->normalizeNullableString($subuser->name->value()) ?? 'Chauffør';
|
||||||
|
$message = '<p>Din verifikationskode til Truck Wash er <strong>' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</strong>.</p>'
|
||||||
|
. '<p>Koden udløber om 10 minutter.</p>';
|
||||||
|
(new email())->sendEmail($destination, $recipientName, 'Truck Wash verifikationskode', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markVerified(subusers_o $subuser, string $channel): void
|
||||||
|
{
|
||||||
|
$timestamp = date('Y-m-d H:i:s', $this->now());
|
||||||
|
if ($channel === self::CHANNEL_EMAIL) {
|
||||||
|
$subuser->email_verified_at->set($timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subuser->phone_verified_at->set($timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markUnverified(subusers_o $subuser, string $channel): void
|
||||||
|
{
|
||||||
|
if ($channel === self::CHANNEL_EMAIL) {
|
||||||
|
$subuser->email_verified_at->set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subuser->phone_verified_at->set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifiedAtValue(subusers_o $subuser, string $channel): ?string
|
||||||
|
{
|
||||||
|
$value = $channel === self::CHANNEL_EMAIL
|
||||||
|
? $subuser->email_verified_at->value()
|
||||||
|
: $subuser->phone_verified_at->value();
|
||||||
|
$value = $this->normalizeNullableString($value);
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verificationState(array $emailStatus, array $phoneStatus): string
|
||||||
|
{
|
||||||
|
if (!$phoneStatus['available'] && !$emailStatus['available']) {
|
||||||
|
return 'missing_contacts';
|
||||||
|
}
|
||||||
|
if (!$phoneStatus['available']) {
|
||||||
|
return 'missing_phone';
|
||||||
|
}
|
||||||
|
if (!$emailStatus['available']) {
|
||||||
|
return 'missing_email';
|
||||||
|
}
|
||||||
|
if ($phoneStatus['verified'] && $emailStatus['verified']) {
|
||||||
|
return 'verified';
|
||||||
|
}
|
||||||
|
if ($phoneStatus['verified'] || $emailStatus['verified']) {
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unverified';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeNullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string)$value);
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskEmail(string $email): string
|
||||||
|
{
|
||||||
|
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||||
|
$prefix = substr($local, 0, 2);
|
||||||
|
return $prefix . str_repeat('*', max(2, strlen($local) - 2)) . '@' . $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskPhone(string $phone): string
|
||||||
|
{
|
||||||
|
$suffix = substr($phone, -4);
|
||||||
|
return str_repeat('*', max(0, strlen($phone) - 4)) . $suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function delivery(string $channel, string $status, string $message, array $extra = []): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'channel' => $channel,
|
||||||
|
'status' => $status,
|
||||||
|
'message' => $message,
|
||||||
|
...$extra,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verificationResult(string $channel, string $status, string $message, array $extra = []): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'channel' => $channel,
|
||||||
|
'status' => $status,
|
||||||
|
'message' => $message,
|
||||||
|
...$extra,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logEvent(string $action, subusers_o $subuser, string $channel, array $actor): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!defined('redis')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$actorId = isset($actor['id']) ? (int)$actor['id'] : 0;
|
||||||
|
(new logs_o())->add(
|
||||||
|
'subusers',
|
||||||
|
'global',
|
||||||
|
1,
|
||||||
|
$actorId,
|
||||||
|
$action,
|
||||||
|
'Chauffeur contact verification ' . $channel . ': ' . (int)$subuser->id
|
||||||
|
);
|
||||||
|
} catch (Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<?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']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{enabled:bool,permissions:array<int,string>}|null
|
||||||
|
*/
|
||||||
|
public function expandTemplateForWritePayload(?string $templateKey): ?array
|
||||||
|
{
|
||||||
|
$key = $this->normalizeTemplateKey($templateKey);
|
||||||
|
if ($key === self::TEMPLATE_CUSTOM) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === null) {
|
||||||
|
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expandTemplate($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class subusers_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::ensureColumn(
|
||||||
|
'subuser_grants',
|
||||||
|
'assigned_vehicle_id',
|
||||||
|
'INT NULL AFTER `subuser`'
|
||||||
|
);
|
||||||
|
self::ensureIndex(
|
||||||
|
'subuser_grants',
|
||||||
|
'idx_subuser_grants_assigned_vehicle_id',
|
||||||
|
'`assigned_vehicle_id`'
|
||||||
|
);
|
||||||
|
self::ensureColumn(
|
||||||
|
'subusers',
|
||||||
|
'phone_verified_at',
|
||||||
|
'DATETIME NULL AFTER `phone`'
|
||||||
|
);
|
||||||
|
self::ensureColumn(
|
||||||
|
'subusers',
|
||||||
|
'email_verified_at',
|
||||||
|
'DATETIME NULL AFTER `email`'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||||
|
if ($table === '' || $column === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnSql = $db->escape_string($column);
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$columnSql'");
|
||||||
|
if ($result !== false && $result->num_rows > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||||
|
if ($table === '' || $index === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexSql = $db->escape_string($index);
|
||||||
|
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||||
|
if ($result !== false && $result->num_rows > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,6 @@ class superuser_system_status_service
|
|||||||
$dependencies['database']['status'] ?? 'down',
|
$dependencies['database']['status'] ?? 'down',
|
||||||
$dependencies['redis']['status'] ?? 'down',
|
$dependencies['redis']['status'] ?? 'down',
|
||||||
$dependencies['minio']['status'] ?? 'down',
|
$dependencies['minio']['status'] ?? 'down',
|
||||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
|
||||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
|
||||||
$dependencies['minio']['replication']['status'] ?? 'not_configured',
|
|
||||||
];
|
];
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
if (($module['enabled'] ?? false) === true) {
|
if (($module['enabled'] ?? false) === true) {
|
||||||
@@ -298,16 +295,6 @@ class superuser_system_status_service
|
|||||||
$database = $this->probeDatabase();
|
$database = $this->probeDatabase();
|
||||||
$redis = $this->probeRedis();
|
$redis = $this->probeRedis();
|
||||||
$minio = $this->probeMinio();
|
$minio = $this->probeMinio();
|
||||||
try {
|
|
||||||
$replicationManager = new replication_manager();
|
|
||||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
|
||||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
|
||||||
$minio['replication'] = $replicationManager->dependencyReplication('minio');
|
|
||||||
} catch (Throwable $throwable) {
|
|
||||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
|
||||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
|
||||||
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($redis['status'] ?? '') === 'down') {
|
if (($redis['status'] ?? '') === 'down') {
|
||||||
$this->pushWarning(
|
$this->pushWarning(
|
||||||
@@ -333,19 +320,6 @@ class superuser_system_status_service
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function replicationStatusFallback(string $kind, Throwable $throwable): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'status' => 'degraded',
|
|
||||||
'min_percent' => 0.0,
|
|
||||||
'average_percent' => 0.0,
|
|
||||||
'replicas' => [],
|
|
||||||
'blockers' => [
|
|
||||||
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function probeCpu(array &$warnings): array
|
private function probeCpu(array &$warnings): array
|
||||||
{
|
{
|
||||||
$checkedAt = date('c');
|
$checkedAt = date('c');
|
||||||
@@ -563,6 +537,7 @@ class superuser_system_status_service
|
|||||||
$result['configured'] = false;
|
$result['configured'] = false;
|
||||||
$result['status'] = 'disabled';
|
$result['status'] = 'disabled';
|
||||||
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
|
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
|
||||||
|
$result = $this->attachModuleUsageMetrics($result);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -574,6 +549,7 @@ class superuser_system_status_service
|
|||||||
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
|
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
|
||||||
? $configuration['reason_params']
|
? $configuration['reason_params']
|
||||||
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
|
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
|
||||||
|
$result = $this->attachModuleUsageMetrics($result);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -589,6 +565,7 @@ class superuser_system_status_service
|
|||||||
'Configuration is present, but no safe read-only probe is available.'
|
'Configuration is present, but no safe read-only probe is available.'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
$result = $this->attachModuleUsageMetrics($result);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -601,6 +578,10 @@ class superuser_system_status_service
|
|||||||
? $probeResult['status_reason_params']
|
? $probeResult['status_reason_params']
|
||||||
: [];
|
: [];
|
||||||
$result['checked_at'] = (string)($probeResult['checked_at'] ?? $result['checked_at']);
|
$result['checked_at'] = (string)($probeResult['checked_at'] ?? $result['checked_at']);
|
||||||
|
if (isset($probeResult['usage']) && is_array($probeResult['usage'])) {
|
||||||
|
$result['usage'] = $probeResult['usage'];
|
||||||
|
}
|
||||||
|
$result = $this->attachModuleUsageMetrics($result, isset($probeResult['usage']) && is_array($probeResult['usage']) ? $probeResult['usage'] : null);
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +600,60 @@ class superuser_system_status_service
|
|||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function attachModuleUsageMetrics(array $moduleResult, ?array $probeUsage = null): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$service = new module_usage_service();
|
||||||
|
$metrics = $service->metricsForModule((string)($moduleResult['key'] ?? ''));
|
||||||
|
|
||||||
|
if ($probeUsage !== null) {
|
||||||
|
$probeMetric = $service->recordProviderSnapshotFromLegacyUsage(
|
||||||
|
(string)($moduleResult['key'] ?? ''),
|
||||||
|
$probeUsage,
|
||||||
|
['source' => 'system_status_probe']
|
||||||
|
);
|
||||||
|
if ($probeMetric !== null) {
|
||||||
|
$metrics = $this->replaceModuleUsageMetric($metrics, $probeMetric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($metrics !== []) {
|
||||||
|
$moduleResult['usage_metrics'] = $metrics;
|
||||||
|
if (!isset($moduleResult['usage'])) {
|
||||||
|
$primaryUsage = $service->primarySystemUsage($metrics);
|
||||||
|
if ($primaryUsage !== null) {
|
||||||
|
$moduleResult['usage'] = $primaryUsage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$moduleResult['usage_metrics_error'] = $throwable->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $moduleResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function replaceModuleUsageMetric(array $metrics, array $replacement): array
|
||||||
|
{
|
||||||
|
$replaced = false;
|
||||||
|
foreach ($metrics as $index => $metric) {
|
||||||
|
if (
|
||||||
|
($metric['module_key'] ?? null) === ($replacement['module_key'] ?? null)
|
||||||
|
&& ($metric['metric_key'] ?? null) === ($replacement['metric_key'] ?? null)
|
||||||
|
) {
|
||||||
|
$metrics[$index] = $replacement;
|
||||||
|
$replaced = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$replaced) {
|
||||||
|
$metrics[] = $replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $metrics;
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
|
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
|
||||||
{
|
{
|
||||||
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
|
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
|
||||||
@@ -824,7 +859,7 @@ class superuser_system_status_service
|
|||||||
protected function moduleDescriptors(): array
|
protected function moduleDescriptors(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)],
|
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'invoiceDiscountLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)],
|
||||||
['key' => 'reCAPTCHA', 'module' => 'reCAPTCHA', 'enabled_variable' => 'enabled', 'required' => ['site_key_v2', 'secret_key_v2'], 'probe' => fn(array $config): array => $this->probeRecaptchaModule($config)],
|
['key' => 'reCAPTCHA', 'module' => 'reCAPTCHA', 'enabled_variable' => 'enabled', 'required' => ['site_key_v2', 'secret_key_v2'], 'probe' => fn(array $config): array => $this->probeRecaptchaModule($config)],
|
||||||
['key' => 'email', 'module' => 'Email', 'enabled_variable' => 'enabled', 'required' => ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_encryption', 'smtp_from', 'smtp_from_name', 'smtp_reply_to', 'smtp_reply_to_name'], 'probe' => fn(array $config): array => $this->probeEmailModule($config)],
|
['key' => 'email', 'module' => 'Email', 'enabled_variable' => 'enabled', 'required' => ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_encryption', 'smtp_from', 'smtp_from_name', 'smtp_reply_to', 'smtp_reply_to_name'], 'probe' => fn(array $config): array => $this->probeEmailModule($config)],
|
||||||
['key' => 'backups', 'module' => 'Backups', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeBackupsModule($config)],
|
['key' => 'backups', 'module' => 'Backups', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeBackupsModule($config)],
|
||||||
@@ -979,7 +1014,11 @@ class superuser_system_status_service
|
|||||||
'Authorization: Bearer ' . $apiKey,
|
'Authorization: Bearer ' . $apiKey,
|
||||||
'Accept: application/json',
|
'Accept: application/json',
|
||||||
],
|
],
|
||||||
'MailerSend API'
|
'MailerSend API',
|
||||||
|
null,
|
||||||
|
'GET',
|
||||||
|
null,
|
||||||
|
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,12 +1029,50 @@ class superuser_system_status_service
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->validateBackupsStore();
|
$this->validateBackupsStore();
|
||||||
|
$health = $this->backupHealthSummary();
|
||||||
|
|
||||||
|
if (empty($health['encryption']['available'])) {
|
||||||
|
$error = (string)($health['encryption']['error'] ?? 'Backup encryption key is missing.');
|
||||||
|
return [
|
||||||
|
'status' => 'down',
|
||||||
|
'status_reason' => 'Backup encryption is not ready: ' . $error,
|
||||||
|
'status_reason_key' => 'backup_encryption_key_missing',
|
||||||
|
'status_reason_params' => ['error' => $error],
|
||||||
|
'checked_at' => $checkedAt,
|
||||||
|
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($health['latest_verified_backup'])) {
|
||||||
|
return [
|
||||||
|
'status' => 'degraded',
|
||||||
|
'status_reason' => 'No verified backup is available for restore.',
|
||||||
|
'status_reason_key' => 'backup_no_verified_backup',
|
||||||
|
'status_reason_params' => [],
|
||||||
|
'checked_at' => $checkedAt,
|
||||||
|
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($health['fresh'])) {
|
||||||
|
return [
|
||||||
|
'status' => 'degraded',
|
||||||
|
'status_reason' => 'Latest verified backup is stale.',
|
||||||
|
'status_reason_key' => 'backup_latest_verified_stale',
|
||||||
|
'status_reason_params' => ['age_seconds' => (string)($health['latest_verified_age_seconds'] ?? '')],
|
||||||
|
'checked_at' => $checkedAt,
|
||||||
|
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'status_reason' => 'Backup store connectivity confirmed.',
|
'status_reason' => 'Backup store connectivity confirmed.',
|
||||||
'status_reason_key' => 'backup_connectivity_confirmed',
|
'status_reason_key' => 'backup_connectivity_confirmed',
|
||||||
'status_reason_params' => [],
|
'status_reason_params' => [
|
||||||
|
'latest_verified_age_seconds' => (string)($health['latest_verified_age_seconds'] ?? ''),
|
||||||
|
'encryption_key_id' => (string)($health['encryption']['key_id'] ?? ''),
|
||||||
|
],
|
||||||
'checked_at' => $checkedAt,
|
'checked_at' => $checkedAt,
|
||||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||||
];
|
];
|
||||||
@@ -1018,7 +1095,11 @@ class superuser_system_status_service
|
|||||||
return $this->performHttpProbe(
|
return $this->performHttpProbe(
|
||||||
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
|
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
|
||||||
['X-AUTH-TOKEN: ' . $secretKey],
|
['X-AUTH-TOKEN: ' . $secretKey],
|
||||||
'MotorAPI'
|
'MotorAPI',
|
||||||
|
null,
|
||||||
|
'GET',
|
||||||
|
null,
|
||||||
|
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,12 +1232,16 @@ class superuser_system_status_service
|
|||||||
$apiKey = trim((string)($config['api_key']['parsed'] ?? ''));
|
$apiKey = trim((string)($config['api_key']['parsed'] ?? ''));
|
||||||
|
|
||||||
return $this->performHttpProbe(
|
return $this->performHttpProbe(
|
||||||
$this->buildUrlWithQuery('https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk', '/info/'),
|
$this->buildUrlWithQuery(licenseplaterecognizer::configuredApiBaseUrl(), '/info/'),
|
||||||
[
|
[
|
||||||
'Authorization: Token ' . $apiKey,
|
'Authorization: Token ' . $apiKey,
|
||||||
'Accept: application/json',
|
'Accept: application/json',
|
||||||
],
|
],
|
||||||
'License Plate Recognizer'
|
'License Plate Recognizer',
|
||||||
|
null,
|
||||||
|
'GET',
|
||||||
|
null,
|
||||||
|
fn(array $httpResponse, string $label): array => $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1474,6 +1559,270 @@ class superuser_system_status_service
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function evaluateLicensePlateRecognizerProbeResponse(array $httpResponse, string $label): array
|
||||||
|
{
|
||||||
|
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||||
|
if (($classified['status'] ?? 'down') !== 'ok') {
|
||||||
|
return $classified;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
'licenseplaterecognizer',
|
||||||
|
'unreadable_payload',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : [];
|
||||||
|
$callsUsedRaw = $usage['calls'] ?? null;
|
||||||
|
$quotaCallsRaw = $decoded['total_calls'] ?? null;
|
||||||
|
if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) {
|
||||||
|
$reason = !is_numeric($quotaCallsRaw) ? 'missing_limit' : 'missing_usage';
|
||||||
|
if (is_numeric($quotaCallsRaw) && (int)$quotaCallsRaw <= 0) {
|
||||||
|
$reason = 'invalid_limit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
'licenseplaterecognizer',
|
||||||
|
$reason,
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$callsUsed = max(0, (int)$callsUsedRaw);
|
||||||
|
$quotaCalls = max(1, (int)$quotaCallsRaw);
|
||||||
|
$callsRemaining = max(0, $quotaCalls - $callsUsed);
|
||||||
|
$usagePercent = round(($callsUsed / $quotaCalls) * 100, 2);
|
||||||
|
$usagePayload = [
|
||||||
|
'provider' => 'licenseplaterecognizer',
|
||||||
|
'calls_used' => $callsUsed,
|
||||||
|
'quota_calls' => $quotaCalls,
|
||||||
|
'calls_remaining' => $callsRemaining,
|
||||||
|
'usage_percent' => $usagePercent,
|
||||||
|
'version' => trim((string)($decoded['version'] ?? '')),
|
||||||
|
];
|
||||||
|
$reasonParams = [
|
||||||
|
'label' => $label,
|
||||||
|
'used' => (string)$callsUsed,
|
||||||
|
'quota' => (string)$quotaCalls,
|
||||||
|
'remaining' => (string)$callsRemaining,
|
||||||
|
'percent' => number_format($usagePercent, 1, '.', ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($callsRemaining <= 0 || $usagePercent >= 100.0) {
|
||||||
|
return [
|
||||||
|
'status' => 'down',
|
||||||
|
'status_reason' => $label . ' quota is exhausted.',
|
||||||
|
'status_reason_key' => 'licenseplaterecognizer_quota_exhausted',
|
||||||
|
'status_reason_params' => $reasonParams,
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usagePercent >= 90.0) {
|
||||||
|
return [
|
||||||
|
'status' => 'degraded',
|
||||||
|
'status_reason' => $label . ' quota usage is near the limit.',
|
||||||
|
'status_reason_key' => 'licenseplaterecognizer_quota_near_limit',
|
||||||
|
'status_reason_params' => $reasonParams,
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'status_reason' => $label . ' usage and quota are available.',
|
||||||
|
'status_reason_key' => 'licenseplaterecognizer_usage_available',
|
||||||
|
'status_reason_params' => $reasonParams,
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function evaluateProviderQuotaProbeResponse(array $httpResponse, string $label, string $moduleKey): array
|
||||||
|
{
|
||||||
|
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||||
|
if (($classified['status'] ?? 'down') !== 'ok') {
|
||||||
|
return $classified;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'unreadable_payload',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$used = $this->findFirstNumericPayloadValue($decoded, ['used', 'usage', 'calls', 'messages_used', 'used_messages', 'sent', 'total_used']);
|
||||||
|
$limit = $this->findFirstNumericPayloadValue($decoded, ['limit', 'quota', 'total', 'total_calls', 'messages_limit', 'max', 'allowed']);
|
||||||
|
$remaining = $this->findFirstNumericPayloadValue($decoded, ['remaining', 'left', 'available', 'calls_remaining', 'messages_remaining']);
|
||||||
|
|
||||||
|
if ($used === null && $limit !== null && $remaining !== null) {
|
||||||
|
$used = max(0.0, $limit - $remaining);
|
||||||
|
}
|
||||||
|
if ($remaining === null && $used !== null && $limit !== null) {
|
||||||
|
$remaining = max(0.0, $limit - $used);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit === null) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'missing_limit',
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit <= 0) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'invalid_limit',
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($used === null) {
|
||||||
|
return $this->providerQuotaUnavailableProbeResult(
|
||||||
|
$httpResponse,
|
||||||
|
$label,
|
||||||
|
$moduleKey,
|
||||||
|
'missing_usage',
|
||||||
|
$this->payloadKeyPaths($decoded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usagePercent = round(($used / $limit) * 100, 2);
|
||||||
|
$usagePayload = [
|
||||||
|
'provider' => $moduleKey,
|
||||||
|
'calls_used' => $used,
|
||||||
|
'quota_calls' => $limit,
|
||||||
|
'calls_remaining' => $remaining,
|
||||||
|
'usage_percent' => $usagePercent,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (($remaining !== null && $remaining <= 0) || $usagePercent >= 100.0) {
|
||||||
|
return [
|
||||||
|
'status' => 'down',
|
||||||
|
'status_reason' => $label . ' quota is exhausted.',
|
||||||
|
'status_reason_key' => 'provider_quota_exhausted',
|
||||||
|
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usagePercent >= 90.0) {
|
||||||
|
return [
|
||||||
|
'status' => 'degraded',
|
||||||
|
'status_reason' => $label . ' quota usage is near the limit.',
|
||||||
|
'status_reason_key' => 'provider_quota_near_limit',
|
||||||
|
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'status_reason' => $label . ' quota usage is available.',
|
||||||
|
'status_reason_key' => 'provider_quota_available',
|
||||||
|
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function providerQuotaUnavailableProbeResult(array $httpResponse, string $label, string $moduleKey, string $reason, array $detectedKeys = []): array
|
||||||
|
{
|
||||||
|
$usagePayload = [
|
||||||
|
'provider' => $moduleKey,
|
||||||
|
'usage_available' => false,
|
||||||
|
'status' => 'unknown',
|
||||||
|
'calls_used' => null,
|
||||||
|
'quota_calls' => null,
|
||||||
|
'calls_remaining' => null,
|
||||||
|
'usage_percent' => null,
|
||||||
|
'unavailable_reason' => $reason,
|
||||||
|
'detected_keys' => $detectedKeys,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'degraded',
|
||||||
|
'status_reason' => $label . ' responded, but quota usage could not be read.',
|
||||||
|
'status_reason_key' => 'provider_quota_unavailable',
|
||||||
|
'status_reason_params' => [
|
||||||
|
'label' => $label,
|
||||||
|
'reason' => $reason,
|
||||||
|
'detected_keys' => implode(', ', array_slice($detectedKeys, 0, 12)),
|
||||||
|
],
|
||||||
|
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||||
|
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||||
|
'http_status' => $httpResponse['http_status'] ?? null,
|
||||||
|
'usage' => $usagePayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function findFirstNumericPayloadValue(array $payload, array $keys): ?float
|
||||||
|
{
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
$normalizedKey = strtolower((string)$key);
|
||||||
|
if (in_array($normalizedKey, $keys, true) && is_numeric($value)) {
|
||||||
|
return (float)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$nested = $this->findFirstNumericPayloadValue($value, $keys);
|
||||||
|
if ($nested !== null) {
|
||||||
|
return $nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function payloadKeyPaths(array $payload, string $prefix = ''): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
$path = $prefix === '' ? (string)$key : $prefix . '.' . (string)$key;
|
||||||
|
$paths[] = $path;
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
array_push($paths, ...$this->payloadKeyPaths($value, $path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($paths));
|
||||||
|
}
|
||||||
|
|
||||||
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
|
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
|
||||||
{
|
{
|
||||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||||
@@ -1539,6 +1888,11 @@ class superuser_system_status_service
|
|||||||
new backup_store();
|
new backup_store();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function backupHealthSummary(): array
|
||||||
|
{
|
||||||
|
return (new backup_store())->healthSummary();
|
||||||
|
}
|
||||||
|
|
||||||
protected function bootstrapSelfserveSchema(): void
|
protected function bootstrapSelfserveSchema(): void
|
||||||
{
|
{
|
||||||
selfserve_schema_bootstrap::ensureTables();
|
selfserve_schema_bootstrap::ensureTables();
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ use Throwable;
|
|||||||
|
|
||||||
class system_search_cache
|
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 QUERY_PREFIX = self::PREFIX . 'query:';
|
||||||
public const INTENT_PREFIX = self::PREFIX . 'intent:';
|
|
||||||
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
|
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
|
||||||
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
|
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
|
||||||
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
|
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);
|
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
|
public static function clearAll(): void
|
||||||
{
|
{
|
||||||
self::clearPattern(self::PREFIX . '*');
|
self::clearPattern(self::PREFIX . '*');
|
||||||
@@ -68,11 +49,6 @@ class system_search_cache
|
|||||||
self::clearPattern(self::QUERY_PREFIX . '*');
|
self::clearPattern(self::QUERY_PREFIX . '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function clearIntentCaches(): void
|
|
||||||
{
|
|
||||||
self::clearPattern(self::INTENT_PREFIX . '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function markDirtyTable(string $table): void
|
public static function markDirtyTable(string $table): void
|
||||||
{
|
{
|
||||||
$table = trim($table, " `\t\n\r\0\x0B");
|
$table = trim($table, " `\t\n\r\0\x0B");
|
||||||
|
|||||||
@@ -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;
|
namespace classes;
|
||||||
|
|
||||||
use interfaces\system_search_intent_parser_i;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class system_search_service
|
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 $defaultEntityFetchLimit = 200;
|
||||||
|
private int $defaultMaxResults = 50;
|
||||||
private int $maxExpandedTerms = 24;
|
private int $maxExpandedTerms = 24;
|
||||||
private int $maxTermLength = 64;
|
private int $maxTermLength = 64;
|
||||||
private int $recencyScoreTolerance = 12;
|
private int $recencyScoreTolerance = 12;
|
||||||
|
private int $minimumEffectiveScore = 45;
|
||||||
|
private int $minimumExplicitTypeScore = 20;
|
||||||
|
private int $associationSeedScoreThreshold = 80;
|
||||||
private array $rankingBoostByType = [
|
private array $rankingBoostByType = [
|
||||||
'invoices' => 35,
|
'invoices' => 35,
|
||||||
'orders' => 35,
|
'orders' => 35,
|
||||||
@@ -35,9 +35,8 @@ class system_search_service
|
|||||||
private array $tableColumnsCache = [];
|
private array $tableColumnsCache = [];
|
||||||
private array $customerContextCache = [];
|
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 {
|
try {
|
||||||
system_search_economic_customer_index::ensureTable();
|
system_search_economic_customer_index::ensureTable();
|
||||||
system_search_document_index::ensureTable();
|
system_search_document_index::ensureTable();
|
||||||
@@ -59,18 +58,13 @@ class system_search_service
|
|||||||
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
|
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
|
||||||
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
|
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
|
||||||
$includeAssociations = (bool)($options['include_associations'] ?? true);
|
$includeAssociations = (bool)($options['include_associations'] ?? true);
|
||||||
$debugIntent = (bool)($options['debug_intent'] ?? false);
|
|
||||||
|
|
||||||
$limit = (int)($options['limit'] ?? 50);
|
$maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
|
||||||
$offset = (int)($options['offset'] ?? 0);
|
if ($maxResults < 1) {
|
||||||
if ($limit < 1) {
|
$maxResults = $this->defaultMaxResults;
|
||||||
$limit = 50;
|
|
||||||
}
|
}
|
||||||
if ($limit > 200) {
|
if ($maxResults > $this->defaultMaxResults) {
|
||||||
$limit = 200;
|
$maxResults = $this->defaultMaxResults;
|
||||||
}
|
|
||||||
if ($offset < 0) {
|
|
||||||
$offset = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$allTypes = $this->allEntityTypes();
|
$allTypes = $this->allEntityTypes();
|
||||||
@@ -79,11 +73,12 @@ class system_search_service
|
|||||||
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
|
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
|
||||||
}
|
}
|
||||||
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
|
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
|
||||||
|
$terms = $this->buildExpandedTerms($this->tokenize($query));
|
||||||
|
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
|
||||||
|
|
||||||
$baseMeta = [
|
$baseMeta = [
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
'limit' => $limit,
|
'max_results' => $maxResults,
|
||||||
'offset' => $offset,
|
|
||||||
'allowed_types' => $activeTypes,
|
'allowed_types' => $activeTypes,
|
||||||
'cache' => ['hit' => false],
|
'cache' => ['hit' => false],
|
||||||
];
|
];
|
||||||
@@ -94,7 +89,8 @@ class system_search_service
|
|||||||
'grouped_results' => $this->groupResultsByType([]),
|
'grouped_results' => $this->groupResultsByType([]),
|
||||||
'meta' => [
|
'meta' => [
|
||||||
...$baseMeta,
|
...$baseMeta,
|
||||||
'total' => 0,
|
'returned' => 0,
|
||||||
|
'truncated' => false,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -104,16 +100,14 @@ class system_search_service
|
|||||||
'include' => $includeTypes,
|
'include' => $includeTypes,
|
||||||
'exclude' => $excludeTypes,
|
'exclude' => $excludeTypes,
|
||||||
'active' => $activeTypes,
|
'active' => $activeTypes,
|
||||||
'limit' => $limit,
|
'max' => $maxResults,
|
||||||
'offset' => $offset,
|
|
||||||
'own' => $ownCustomerNumber,
|
'own' => $ownCustomerNumber,
|
||||||
'own_only' => $ownOnlyTypes,
|
'own_only' => $ownOnlyTypes,
|
||||||
'dept' => $allowedDepartmentIds,
|
'dept' => $allowedDepartmentIds,
|
||||||
'assoc' => $includeAssociations,
|
'assoc' => $includeAssociations,
|
||||||
'dbg' => $debugIntent,
|
|
||||||
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
||||||
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
|
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
|
||||||
'v' => 12,
|
'v' => 13,
|
||||||
], JSON_UNESCAPED_UNICODE));
|
], JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
$cached = system_search_cache::getQuery($queryCacheHash);
|
$cached = system_search_cache::getQuery($queryCacheHash);
|
||||||
@@ -122,7 +116,6 @@ class system_search_service
|
|||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
$terms = $this->buildExpandedTerms($this->tokenize($query));
|
|
||||||
$entityBoost = [];
|
$entityBoost = [];
|
||||||
$initialResults = $this->executeLexicalSearch(
|
$initialResults = $this->executeLexicalSearch(
|
||||||
$activeTypes,
|
$activeTypes,
|
||||||
@@ -135,76 +128,23 @@ class system_search_service
|
|||||||
$moduleConfigVisibility,
|
$moduleConfigVisibility,
|
||||||
$allowedDepartmentIds
|
$allowedDepartmentIds
|
||||||
);
|
);
|
||||||
|
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
|
||||||
$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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($includeAssociations) {
|
if ($includeAssociations) {
|
||||||
$customerNumbers = [];
|
$customerNumbers = [];
|
||||||
foreach ($initialResults as $result) {
|
foreach ($initialResults as $result) {
|
||||||
if (!isset($result['customer_number'])) {
|
$customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
|
||||||
|
if ($customerNumber === null || $customerNumber <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
|
if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$customerNumbers[] = (int)$result['customer_number'];
|
$customerNumbers[] = $customerNumber;
|
||||||
}
|
}
|
||||||
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
|
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
|
||||||
if (count($customerNumbers) > 15) {
|
if (count($customerNumbers) > 5) {
|
||||||
$customerNumbers = array_slice($customerNumbers, 0, 15);
|
$customerNumbers = array_slice($customerNumbers, 0, 5);
|
||||||
}
|
}
|
||||||
if (!empty($customerNumbers)) {
|
if (!empty($customerNumbers)) {
|
||||||
$associationTypes = array_values(array_intersect(
|
$associationTypes = array_values(array_intersect(
|
||||||
@@ -232,18 +172,21 @@ class system_search_service
|
|||||||
}
|
}
|
||||||
$item['score'] = max((int)$item['score'], 35);
|
$item['score'] = max((int)$item['score'], 35);
|
||||||
}
|
}
|
||||||
|
unset($item);
|
||||||
|
$associated = $this->filterRelevantResults($associated, $includeTypes);
|
||||||
$initialResults = $this->mergeResults($initialResults, $associated);
|
$initialResults = $this->mergeResults($initialResults, $associated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
|
||||||
|
|
||||||
$preferRecency = $this->shouldPreferRecencySort($query, $terms);
|
$preferRecency = $this->shouldPreferRecencySort($query, $terms);
|
||||||
usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
|
usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
|
||||||
$scoreA = (int)($a['score'] ?? 0);
|
$scoreA = (int)($a['score'] ?? 0);
|
||||||
$scoreB = (int)($b['score'] ?? 0);
|
$scoreB = (int)($b['score'] ?? 0);
|
||||||
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a);
|
$effectiveScoreA = $this->effectiveResultScore($a);
|
||||||
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b);
|
$effectiveScoreB = $this->effectiveResultScore($b);
|
||||||
$recencyA = $this->resultRecencyTimestamp($a);
|
$recencyA = $this->resultRecencyTimestamp($a);
|
||||||
$recencyB = $this->resultRecencyTimestamp($b);
|
$recencyB = $this->resultRecencyTimestamp($b);
|
||||||
$cancelledA = $this->isCancelledBookingResult($a);
|
$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']);
|
return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
$total = count($initialResults);
|
$limited = array_slice($initialResults, 0, $maxResults);
|
||||||
$paged = array_slice($initialResults, $offset, $limit);
|
$grouped = $this->groupResultsByType($limited);
|
||||||
$grouped = $this->groupResultsByType($paged);
|
|
||||||
|
|
||||||
$meta = [
|
$meta = [
|
||||||
...$baseMeta,
|
...$baseMeta,
|
||||||
'total' => $total,
|
'returned' => count($limited),
|
||||||
|
'truncated' => count($initialResults) > count($limited),
|
||||||
];
|
];
|
||||||
if ($debugIntent) {
|
|
||||||
$meta['intent_parser'] = $intentMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'results' => $paged,
|
'results' => $limited,
|
||||||
'grouped_results' => $grouped,
|
'grouped_results' => $grouped,
|
||||||
'meta' => $meta,
|
'meta' => $meta,
|
||||||
];
|
];
|
||||||
@@ -294,13 +234,127 @@ class system_search_service
|
|||||||
return $payload;
|
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) {
|
if (empty($activeTypes) || !empty($includeTypes)) {
|
||||||
return true;
|
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,
|
$allowedDepartmentIds,
|
||||||
$forcedCustomerNumbers
|
$forcedCustomerNumbers
|
||||||
);
|
);
|
||||||
if (empty($rows)) {
|
|
||||||
$rows = $this->searchEntity(
|
|
||||||
$entityType,
|
|
||||||
$terms,
|
|
||||||
$boost,
|
|
||||||
$ownOnly,
|
|
||||||
$ownCustomerNumber,
|
|
||||||
$permissionsCatalogAll,
|
|
||||||
$permissionsCatalogOwn,
|
|
||||||
$moduleConfigVisibility,
|
|
||||||
$allowedDepartmentIds,
|
|
||||||
$forcedCustomerNumbers
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
$rows = $this->searchEntity(
|
$rows = $this->searchEntity(
|
||||||
$entityType,
|
$entityType,
|
||||||
@@ -2647,6 +2687,11 @@ class system_search_service
|
|||||||
return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
|
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
|
private function boolishTrue(mixed $value): bool
|
||||||
{
|
{
|
||||||
if (is_bool($value)) {
|
if (is_bool($value)) {
|
||||||
@@ -2706,7 +2751,7 @@ class system_search_service
|
|||||||
return false;
|
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)) {
|
if ($this->queryHasExplicitIdentifier($normalized)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2729,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
|
private function queryHasExplicitIdentifier(string $normalizedQuery): bool
|
||||||
{
|
{
|
||||||
if ($normalizedQuery === '') {
|
if ($normalizedQuery === '') {
|
||||||
@@ -2985,22 +2968,6 @@ class system_search_service
|
|||||||
return system_search_registry::allEntityTypes();
|
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
|
private function groupResultsByType(array $results): array
|
||||||
{
|
{
|
||||||
$grouped = [];
|
$grouped = [];
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ class virkdata implements virkdata_i
|
|||||||
self::requireModuleEnabled();
|
self::requireModuleEnabled();
|
||||||
// Validate the secret key
|
// Validate the secret key
|
||||||
self::requireValidSecretKey();
|
self::requireValidSecretKey();
|
||||||
|
// VirkData has a monthly limit config, but defaults to observe mode until explicitly switched to block.
|
||||||
|
$this->reserveCompanySearchQuota($search, $endpoint, $method);
|
||||||
// Send the request
|
// Send the request
|
||||||
$response = match ($method) {
|
$response = match ($method) {
|
||||||
'GET' => self::sendGetRequest($search, $endpoint, $data),
|
'GET' => self::sendGetRequest($search, $endpoint, $data),
|
||||||
@@ -128,6 +130,18 @@ class virkdata implements virkdata_i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function reserveCompanySearchQuota(string $search, string $endpoint, string $method): void
|
||||||
|
{
|
||||||
|
(new module_usage_service())->reserveOrFail('virkdata', 'company_search_calls', 1, [
|
||||||
|
'search' => $search,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -235,4 +249,4 @@ class virkdata implements virkdata_i
|
|||||||
{
|
{
|
||||||
return $this->sendRequest($query, 'search', [], 'GET');
|
return $this->sendRequest($query, 'search', [], 'GET');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
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
|
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
|
||||||
{
|
{
|
||||||
$usageBag = self::productBagForAutomation($usageItems);
|
$usageBag = self::productBagForAutomation($usageItems);
|
||||||
@@ -600,18 +606,52 @@ class xlvask_automation_service
|
|||||||
|
|
||||||
if ($action === self::ACTION_ATTACH) {
|
if ($action === self::ACTION_ATTACH) {
|
||||||
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
|
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) {
|
if ($action === self::ACTION_CREATE) {
|
||||||
return $xlvask->config->automatic_order_creation_enabled->isTrue()
|
return false;
|
||||||
&& $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
|
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -646,7 +686,7 @@ class xlvask_automation_service
|
|||||||
|
|
||||||
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
|
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
|
||||||
if ($automatic) {
|
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);
|
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
|
private function createOrderFromContext(array $context): orders_o
|
||||||
{
|
{
|
||||||
$orderData = $context['proposed_order'];
|
$orderData = $context['proposed_order'];
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ if ($args[1] === 'run') {
|
|||||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
|
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
|
||||||
require_once 'cron/Cron.php';
|
require_once 'cron/Cron.php';
|
||||||
break;
|
break;
|
||||||
|
case 'cron-worker':
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
|
||||||
|
(new \classes\cron_worker())->run();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
echo "Invalid script name";
|
echo "Invalid script name";
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ $htaccess = "
|
|||||||
# php -- END cPanel-generated handler, do not edit
|
# 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.)
|
// Keep the compatibility rewrite file stable without rewriting it every minute.
|
||||||
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
$htaccessPath = __DIR__ . '/.htaccess';
|
||||||
|
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
|
||||||
|
file_put_contents($htaccessPath, $htaccess);
|
||||||
|
}
|
||||||
|
|
||||||
// Log the time of the cron job
|
// 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);
|
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\coolify_manager;
|
||||||
use classes\replication_manager;
|
use classes\replication_manager;
|
||||||
use classes\redis;
|
use classes\redis;
|
||||||
|
use classes\selfserve;
|
||||||
use classes\system_search_cache;
|
use classes\system_search_cache;
|
||||||
use classes\system_search_document_index;
|
use classes\system_search_document_index;
|
||||||
use classes\system_search_economic_customer_index;
|
use classes\system_search_economic_customer_index;
|
||||||
@@ -39,7 +40,14 @@ require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
|||||||
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
||||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||||
|
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../classes/backup_store.php';
|
||||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.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')) {
|
if (!defined('WD')) {
|
||||||
exit;
|
exit;
|
||||||
@@ -532,12 +540,34 @@ function backup(): void
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$backup = new backup_store();
|
$backup = new backup_store();
|
||||||
$backup->createBackup();
|
$backup->enqueueCreateBackup(null, null, null, 'scheduled');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
warn('Backup failed: ' . $e->getMessage());
|
warn('Backup failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processBackupJobs(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (new backup_store())->processPendingJobs(3);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
warn('Backup job processing failed: ' . $e->getMessage());
|
||||||
|
return ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneBackupRetention(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$backup = new backup_store();
|
||||||
|
$queued = $backup->enqueueRetentionPrune();
|
||||||
|
return $backup->runJobById((int)$queued['job_id']);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
warn('Backup retention prune failed: ' . $e->getMessage());
|
||||||
|
return ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function SyncUserEconomicCustomerDiscounts(): void
|
function SyncUserEconomicCustomerDiscounts(): void
|
||||||
{
|
{
|
||||||
$users_o = new users_o();
|
$users_o = new users_o();
|
||||||
@@ -652,7 +682,6 @@ function SystemSearchCacheMaintenanceCron(): void
|
|||||||
|
|
||||||
if ($rebuildRequest !== null) {
|
if ($rebuildRequest !== null) {
|
||||||
system_search_cache::clearQueryCaches();
|
system_search_cache::clearQueryCaches();
|
||||||
system_search_cache::clearIntentCaches();
|
|
||||||
$scope = (string)($rebuildRequest['scope'] ?? 'all');
|
$scope = (string)($rebuildRequest['scope'] ?? 'all');
|
||||||
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
|
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
|
||||||
if ($scope === 'types' && !empty($types)) {
|
if ($scope === 'types' && !empty($types)) {
|
||||||
@@ -1470,17 +1499,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ( $cron_tasks as $task => $data ) {
|
function SelfserveOpeningRelayActivationCron(): array
|
||||||
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
|
{
|
||||||
$nextRun = $lastRun + $data['interval'];
|
global $db;
|
||||||
$cron_tasks[$task]['last_run'] = $lastRun;
|
|
||||||
$cron_tasks[$task]['next_run'] = $nextRun;
|
if (!($db instanceof \classes\db)) {
|
||||||
if ($nextRun <= time()) {
|
warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
|
||||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
|
return [
|
||||||
$data['function']();
|
'skipped' => true,
|
||||||
redis->set_last_crond_run($task, time());
|
'reason' => 'database_unavailable',
|
||||||
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)';
|
|
||||||
|
$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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface minio_backups_i
|
|||||||
* Create a backup, upload it to the Minio bucket and return the UUID
|
* Create a backup, upload it to the Minio bucket and return the UUID
|
||||||
* @return string The UUID of the backup
|
* @return string The UUID of the backup
|
||||||
*/
|
*/
|
||||||
public function createBackup(): string;
|
public function createBackup($backup_name = null, $backup_description = null): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the metadata for a backup
|
* Create the metadata for a backup
|
||||||
@@ -98,4 +98,4 @@ interface minio_backups_i
|
|||||||
*/
|
*/
|
||||||
public function backupEnvironmentVariables(string $backup_uuid): string;
|
public function backupEnvironmentVariables(string $backup_uuid): string;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -2,8 +2,22 @@
|
|||||||
|
|
||||||
namespace backups;
|
namespace backups;
|
||||||
require_once WD . '/modules/backups/config/backups_enabled_c.php';
|
require_once WD . '/modules/backups/config/backups_enabled_c.php';
|
||||||
|
require_once WD . '/modules/backups/config/backups_retention_recent_hours_c.php';
|
||||||
|
require_once WD . '/modules/backups/config/backups_retention_daily_days_c.php';
|
||||||
|
require_once WD . '/modules/backups/config/backups_retention_weekly_weeks_c.php';
|
||||||
|
require_once WD . '/modules/backups/config/backups_retention_monthly_months_c.php';
|
||||||
|
require_once WD . '/modules/backups/config/backups_app_data_enabled_c.php';
|
||||||
|
require_once WD . '/modules/backups/config/backups_verification_required_c.php';
|
||||||
|
require_once WD . '/modules/backups/config/backups_restore_enabled_c.php';
|
||||||
|
|
||||||
|
use backups\config\backups_app_data_enabled_c;
|
||||||
use backups\config\backups_enabled_c;
|
use backups\config\backups_enabled_c;
|
||||||
|
use backups\config\backups_restore_enabled_c;
|
||||||
|
use backups\config\backups_retention_daily_days_c;
|
||||||
|
use backups\config\backups_retention_monthly_months_c;
|
||||||
|
use backups\config\backups_retention_recent_hours_c;
|
||||||
|
use backups\config\backups_retention_weekly_weeks_c;
|
||||||
|
use backups\config\backups_verification_required_c;
|
||||||
use traits\module_config_t;
|
use traits\module_config_t;
|
||||||
|
|
||||||
class backups_c
|
class backups_c
|
||||||
@@ -15,6 +29,13 @@ class backups_c
|
|||||||
* @var backups_enabled_c
|
* @var backups_enabled_c
|
||||||
*/
|
*/
|
||||||
public backups_enabled_c $enabled;
|
public backups_enabled_c $enabled;
|
||||||
|
public backups_retention_recent_hours_c $retention_recent_hours;
|
||||||
|
public backups_retention_daily_days_c $retention_daily_days;
|
||||||
|
public backups_retention_weekly_weeks_c $retention_weekly_weeks;
|
||||||
|
public backups_retention_monthly_months_c $retention_monthly_months;
|
||||||
|
public backups_app_data_enabled_c $app_data_enabled;
|
||||||
|
public backups_verification_required_c $verification_required;
|
||||||
|
public backups_restore_enabled_c $restore_enabled;
|
||||||
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -22,8 +43,22 @@ class backups_c
|
|||||||
$this->setupConfig('Backups');
|
$this->setupConfig('Backups');
|
||||||
$this->allowUpdate([
|
$this->allowUpdate([
|
||||||
backups_enabled_c::class,
|
backups_enabled_c::class,
|
||||||
|
backups_retention_recent_hours_c::class,
|
||||||
|
backups_retention_daily_days_c::class,
|
||||||
|
backups_retention_weekly_weeks_c::class,
|
||||||
|
backups_retention_monthly_months_c::class,
|
||||||
|
backups_app_data_enabled_c::class,
|
||||||
|
backups_verification_required_c::class,
|
||||||
|
backups_restore_enabled_c::class,
|
||||||
]);
|
]);
|
||||||
$this->enabled = new backups_enabled_c();
|
$this->enabled = new backups_enabled_c();
|
||||||
|
$this->retention_recent_hours = new backups_retention_recent_hours_c();
|
||||||
|
$this->retention_daily_days = new backups_retention_daily_days_c();
|
||||||
|
$this->retention_weekly_weeks = new backups_retention_weekly_weeks_c();
|
||||||
|
$this->retention_monthly_months = new backups_retention_monthly_months_c();
|
||||||
|
$this->app_data_enabled = new backups_app_data_enabled_c();
|
||||||
|
$this->verification_required = new backups_verification_required_c();
|
||||||
|
$this->restore_enabled = new backups_restore_enabled_c();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace backups\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class backups_app_data_enabled_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Backups',
|
||||||
|
'app_data_enabled',
|
||||||
|
'bool',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'Whether app-owned object storage buckets are included in backups.',
|
||||||
|
'1',
|
||||||
|
false,
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace backups\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class backups_restore_enabled_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Backups',
|
||||||
|
'restore_enabled',
|
||||||
|
'bool',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'Whether direct production restore execution is enabled for superusers.',
|
||||||
|
'0',
|
||||||
|
false,
|
||||||
|
'false'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace backups\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class backups_retention_daily_days_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Backups',
|
||||||
|
'retention_daily_days',
|
||||||
|
'int',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'How many days of daily backup representatives are retained.',
|
||||||
|
'30',
|
||||||
|
false,
|
||||||
|
'30'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace backups\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class backups_retention_monthly_months_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Backups',
|
||||||
|
'retention_monthly_months',
|
||||||
|
'int',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'How many months of monthly backup representatives are retained.',
|
||||||
|
'3',
|
||||||
|
false,
|
||||||
|
'3'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace backups\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class backups_retention_recent_hours_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Backups',
|
||||||
|
'retention_recent_hours',
|
||||||
|
'int',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'How many hours of recent recovery points are protected before tiered pruning.',
|
||||||
|
'48',
|
||||||
|
false,
|
||||||
|
'48'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace backups\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class backups_retention_weekly_weeks_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Backups',
|
||||||
|
'retention_weekly_weeks',
|
||||||
|
'int',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'How many weeks of weekly backup representatives are retained.',
|
||||||
|
'8',
|
||||||
|
false,
|
||||||
|
'8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace backups\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class backups_verification_required_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Backups',
|
||||||
|
'verification_required',
|
||||||
|
'bool',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'Whether a backup must pass verification before it is marked available for restore.',
|
||||||
|
'1',
|
||||||
|
false,
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'backups.create_backup',
|
||||||
|
'legacy_name' => 'backup',
|
||||||
|
'name' => 'Enqueue backup',
|
||||||
|
'description' => 'Queues the scheduled production recovery backup through the configured backup store.',
|
||||||
|
'module' => 'backups',
|
||||||
|
'handler' => 'backup',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 3600],
|
||||||
|
'timeout_seconds' => 120,
|
||||||
|
'estimated_duration_ms' => 5000,
|
||||||
|
'priority' => 115,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'backups.process_jobs',
|
||||||
|
'legacy_name' => null,
|
||||||
|
'name' => 'Process backup jobs',
|
||||||
|
'description' => 'Processes queued backup create, verify, restore, and prune jobs.',
|
||||||
|
'module' => 'backups',
|
||||||
|
'handler' => 'processBackupJobs',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||||
|
'timeout_seconds' => 3600,
|
||||||
|
'estimated_duration_ms' => 60000,
|
||||||
|
'priority' => 116,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'backups.prune_retention',
|
||||||
|
'legacy_name' => null,
|
||||||
|
'name' => 'Prune backup retention',
|
||||||
|
'description' => 'Queues tiered retention cleanup for old backup artifacts.',
|
||||||
|
'module' => 'backups',
|
||||||
|
'handler' => 'pruneBackupRetention',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||||
|
'timeout_seconds' => 1800,
|
||||||
|
'estimated_duration_ms' => 30000,
|
||||||
|
'priority' => 117,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["User Bookings"],
|
"tags": ["User Bookings"],
|
||||||
"summary": "Get download link for a booking's wash certificate",
|
"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" } ],
|
"parameters": [ { "$ref": "#/components/parameters/id" } ],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"tags": ["User Bookings"],
|
"tags": ["User Bookings"],
|
||||||
"summary": "Get download link for a booking's wash certificate PDF",
|
"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" } ],
|
"parameters": [ { "$ref": "#/components/parameters/id" } ],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
||||||
@@ -213,6 +213,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"/department/timebookings/departments/public": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Public Time Bookings"],
|
||||||
|
"summary": "List public departments with time bookings enabled",
|
||||||
|
"description": "Returns only visible, active departments where `bookingsystem_time_based_enabled` is `true`.",
|
||||||
|
"parameters": [ { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/search" }, { "$ref": "#/components/parameters/filters" }, { "$ref": "#/components/parameters/order" } ],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Departments", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopePublicTimeBookingDepartmentsList" } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"/department/timebookings/opening-hours/public": {
|
"/department/timebookings/opening-hours/public": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Public Time Bookings"],
|
"tags": ["Public Time Bookings"],
|
||||||
@@ -339,6 +351,12 @@
|
|||||||
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/OpeningHours" } } }
|
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/OpeningHours" } } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"EnvelopePublicTimeBookingDepartmentsList": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/Envelope" },
|
||||||
|
{ "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/PublicTimeBookingDepartment" } } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
"EnvelopeBookingTypesList": {
|
"EnvelopeBookingTypesList": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{ "$ref": "#/components/schemas/Envelope" },
|
{ "$ref": "#/components/schemas/Envelope" },
|
||||||
@@ -396,6 +414,21 @@
|
|||||||
"required": ["link"]
|
"required": ["link"]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"PublicTimeBookingDepartment": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"address": { "type": "string" },
|
||||||
|
"longitude": { "type": "number", "format": "float" },
|
||||||
|
"latitude": { "type": "number", "format": "float" },
|
||||||
|
"order_priority": { "type": "integer" },
|
||||||
|
"time_booking_enabled": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"required": ["id", "name", "description", "address", "longitude", "latitude", "order_priority", "time_booking_enabled"]
|
||||||
|
},
|
||||||
|
|
||||||
"OpeningHours": {
|
"OpeningHours": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace config;
|
||||||
|
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class economic_invoice_discount_layout_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'economic',
|
||||||
|
'invoiceDiscountLayoutNumber',
|
||||||
|
'int',
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
'The duplicated invoice layout number used when collected invoices include itemized discounts',
|
||||||
|
'1',
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once WD . '/modules/economic/config/economic_invoice_layout_c.php';
|
require_once WD . '/modules/economic/config/economic_invoice_layout_c.php';
|
||||||
|
require_once WD . '/modules/economic/config/economic_invoice_discount_layout_c.php';
|
||||||
require_once WD . '/modules/economic/config/economic_payment_terms_c.php';
|
require_once WD . '/modules/economic/config/economic_payment_terms_c.php';
|
||||||
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
||||||
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
||||||
@@ -8,6 +9,7 @@ require_once WD . '/modules/economic/config/economic_transaction_draft_customer_
|
|||||||
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
|
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
|
||||||
|
|
||||||
use config\economic_invoice_layout_c;
|
use config\economic_invoice_layout_c;
|
||||||
|
use config\economic_invoice_discount_layout_c;
|
||||||
use config\economic_payment_terms_c;
|
use config\economic_payment_terms_c;
|
||||||
use config\economic_admin_fee_monthly_c;
|
use config\economic_admin_fee_monthly_c;
|
||||||
use config\economic_admin_fee_order_c;
|
use config\economic_admin_fee_order_c;
|
||||||
@@ -21,6 +23,7 @@ class economic_c
|
|||||||
use module_config_t;
|
use module_config_t;
|
||||||
|
|
||||||
public economic_invoice_layout_c $invoice_layout;
|
public economic_invoice_layout_c $invoice_layout;
|
||||||
|
public economic_invoice_discount_layout_c $invoice_discount_layout;
|
||||||
public economic_payment_terms_c $payment_terms;
|
public economic_payment_terms_c $payment_terms;
|
||||||
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
||||||
public economic_admin_fee_order_c $admin_fee_order;
|
public economic_admin_fee_order_c $admin_fee_order;
|
||||||
@@ -33,6 +36,7 @@ class economic_c
|
|||||||
$this->setupConfig('economic');
|
$this->setupConfig('economic');
|
||||||
$this->allowUpdate([
|
$this->allowUpdate([
|
||||||
economic_invoice_layout_c::class,
|
economic_invoice_layout_c::class,
|
||||||
|
economic_invoice_discount_layout_c::class,
|
||||||
economic_payment_terms_c::class,
|
economic_payment_terms_c::class,
|
||||||
economic_admin_fee_monthly_c::class,
|
economic_admin_fee_monthly_c::class,
|
||||||
economic_admin_fee_order_c::class,
|
economic_admin_fee_order_c::class,
|
||||||
@@ -41,6 +45,7 @@ class economic_c
|
|||||||
economic_transaction_draft_customer_number_c::class,
|
economic_transaction_draft_customer_number_c::class,
|
||||||
]);
|
]);
|
||||||
$this->invoice_layout = new economic_invoice_layout_c();
|
$this->invoice_layout = new economic_invoice_layout_c();
|
||||||
|
$this->invoice_discount_layout = new economic_invoice_discount_layout_c();
|
||||||
$this->payment_terms = new economic_payment_terms_c();
|
$this->payment_terms = new economic_payment_terms_c();
|
||||||
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
|
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
|
||||||
$this->admin_fee_order = new economic_admin_fee_order_c();
|
$this->admin_fee_order = new economic_admin_fee_order_c();
|
||||||
|
|||||||
+2
-2
@@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint
|
|||||||
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
* @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
|
* @throws Exception If the request fails
|
||||||
*/
|
*/
|
||||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
|
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
|
||||||
{
|
{
|
||||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||||
$orders_with_invoice_lines = 0;
|
$orders_with_invoice_lines = 0;
|
||||||
@@ -90,7 +90,7 @@ class economic_invoices_draft_endpoint
|
|||||||
// Add the transaction header (Timestamp, department, etc.)
|
// Add the transaction header (Timestamp, department, etc.)
|
||||||
$draftInvoice->addNewTransactionHeader($order);
|
$draftInvoice->addNewTransactionHeader($order);
|
||||||
// Add the order lines
|
// Add the order lines
|
||||||
$draftInvoice->addOrderItemLines($order);
|
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);
|
||||||
// Add an empty line, so the invoice is not empty
|
// Add an empty line, so the invoice is not empty
|
||||||
$draftInvoice->addTextLine('');
|
$draftInvoice->addTextLine('');
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -104,10 +104,12 @@ class economic_invoices_drafts_endpoint
|
|||||||
* @return object {id: number, getExternalId: string}
|
* @return object {id: number, getExternalId: string}
|
||||||
* @throws Exception If the request fails
|
* @throws Exception If the request fails
|
||||||
*/
|
*/
|
||||||
public function add(int $customer_number, string $external_id = '', string|null $date = null): object
|
public function add(int $customer_number, string $external_id = '', string|null $date = null, ?int $layout_number = null): object
|
||||||
{
|
{
|
||||||
// Get the layout number
|
// Get the layout number
|
||||||
$layout_number = (int)(new economic())->config->invoice_layout->getVariableValue();
|
$layout_number = $layout_number !== null && $layout_number > 0
|
||||||
|
? $layout_number
|
||||||
|
: (int)(new economic())->config->invoice_layout->getVariableValue();
|
||||||
|
|
||||||
// Check if the date is set
|
// Check if the date is set
|
||||||
if (is_null($date)) {
|
if (is_null($date)) {
|
||||||
|
|||||||
+33
-5
@@ -9,21 +9,49 @@ class economic_invoices_pdf_endpoint
|
|||||||
use economic_endpoint_t;
|
use economic_endpoint_t;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a PDF of a booked invoice
|
* Get a PDF of a booked invoice.
|
||||||
|
*
|
||||||
|
* Kept as the legacy alias used by existing invoice download flows.
|
||||||
* @param int $id
|
* @param int $id
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public function get(int $id): string
|
public function get(int $id): string
|
||||||
|
{
|
||||||
|
return $this->getBooked($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a PDF of a booked invoice.
|
||||||
|
* @param int $id
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getBooked(int $id): string
|
||||||
|
{
|
||||||
|
return $this->downloadPdf('/invoices/booked/' . $id . '/pdf', 'booked_invoice_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a PDF of a draft invoice.
|
||||||
|
* @param int $id
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getDraft(int $id): string
|
||||||
|
{
|
||||||
|
return $this->downloadPdf('/invoices/drafts/' . $id . '/pdf', 'draft_invoice_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadPdf(string $path, string $prefix): string
|
||||||
{
|
{
|
||||||
$unique_id = uniqid();
|
$unique_id = uniqid();
|
||||||
|
$file_path = '/tmp/' . $prefix . $unique_id . '.pdf';
|
||||||
$this->send_file_download_request(
|
$this->send_file_download_request(
|
||||||
'/invoices/booked/' . $id . '/pdf',
|
$path,
|
||||||
'GET',
|
'GET',
|
||||||
'',
|
'',
|
||||||
false,
|
false,
|
||||||
'/tmp/invoice_' . $unique_id . '.pdf'
|
$file_path
|
||||||
);
|
);
|
||||||
return '/tmp/invoice_' . $unique_id . '.pdf';
|
return $file_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ class economic_invoice_draft
|
|||||||
* @throws Exception if the order is not found
|
* @throws Exception if the order is not found
|
||||||
* @throws Exception if the order is not valid
|
* @throws Exception if the order is not valid
|
||||||
*/
|
*/
|
||||||
public function addOrderItemLines(orders_o $order): void
|
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
|
||||||
{
|
{
|
||||||
// Get the order items
|
// Get the order items
|
||||||
$order_items = $order->getOrderItems($order->id);
|
$order_items = $order->getOrderItems($order->id);
|
||||||
@@ -274,12 +274,12 @@ class economic_invoice_draft
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Add the order item to the draft invoice
|
// Add the order item to the draft invoice
|
||||||
self::addOrderItemLine($order_item, $department);
|
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
|
||||||
// Add the line discount to the total discount
|
// Add the line discount to the total discount
|
||||||
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
|
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
|
||||||
}
|
}
|
||||||
// If the total discount is greater than 0, add it to the invoice
|
// If the total discount is greater than 0, add it to the invoice
|
||||||
if ($total_discount > 0) {
|
if (!$use_itemized_discounts && $total_discount > 0) {
|
||||||
// Add the discount to the invoice
|
// Add the discount to the invoice
|
||||||
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
|
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
|
||||||
}
|
}
|
||||||
@@ -295,7 +295,7 @@ class economic_invoice_draft
|
|||||||
* @throws Exception if the order item is not found
|
* @throws Exception if the order item is not found
|
||||||
* @throws Exception if the order item is not valid
|
* @throws Exception if the order item is not valid
|
||||||
*/
|
*/
|
||||||
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false): void
|
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
|
||||||
{
|
{
|
||||||
// Check if the order item is valid
|
// Check if the order item is valid
|
||||||
if (!isset($order_item['id'])) {
|
if (!isset($order_item['id'])) {
|
||||||
@@ -308,14 +308,21 @@ class economic_invoice_draft
|
|||||||
$economic_department_id = $department['economic_department_id'] ?? 0;
|
$economic_department_id = $department['economic_department_id'] ?? 0;
|
||||||
// Get the dimension id
|
// Get the dimension id
|
||||||
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
||||||
|
$pricing = self::resolveOrderItemInvoicePricing($order_item);
|
||||||
|
$discount_percentage = $use_itemized_discount
|
||||||
|
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
|
||||||
|
: 0;
|
||||||
// Add the order item to the draft invoice
|
// Add the order item to the draft invoice
|
||||||
self::addProductLine(
|
self::addProductLine(
|
||||||
(string)$order_item['product']['economic_product_id'],
|
(string)$order_item['product']['economic_product_id'],
|
||||||
(string)$order_item['product']['name'],
|
(string)$order_item['product']['name'],
|
||||||
(int)$order_item['quantity'] ?? 1,
|
(int)$order_item['quantity'] ?? 1,
|
||||||
(int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
|
$use_itemized_discount
|
||||||
|
? $pricing['invoice_unit_price']
|
||||||
|
: (int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
|
||||||
(int)$economic_department_id,
|
(int)$economic_department_id,
|
||||||
$economic_dimension_id
|
$economic_dimension_id,
|
||||||
|
$discount_percentage
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate the discount percentage (If the final price is 0, set the discount percentage to 100)
|
// Calculate the discount percentage (If the final price is 0, set the discount percentage to 100)
|
||||||
@@ -378,11 +385,90 @@ class economic_invoice_draft
|
|||||||
return abs($price) < 0.00001;
|
return abs($price) < 0.00001;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return exact line discount data from the original department price and stored final item price.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* original_unit_price:float,
|
||||||
|
* final_unit_price:float,
|
||||||
|
* invoice_unit_price:float,
|
||||||
|
* quantity:float,
|
||||||
|
* discount_unit_amount:float,
|
||||||
|
* discount_total_amount:float,
|
||||||
|
* discount_percentage:float,
|
||||||
|
* has_discount:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function resolveOrderItemInvoicePricing(array $order_item): array
|
||||||
|
{
|
||||||
|
$quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity'])
|
||||||
|
? (float)$order_item['quantity']
|
||||||
|
: 0.0;
|
||||||
|
$final_unit_price = isset($order_item['price']) && is_numeric($order_item['price'])
|
||||||
|
? (float)$order_item['price']
|
||||||
|
: 0.0;
|
||||||
|
$original_unit_price = isset($order_item['product']['price']) && is_numeric($order_item['product']['price'])
|
||||||
|
? (float)$order_item['product']['price']
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
if (abs($original_unit_price) < 0.00001) {
|
||||||
|
$original_unit_price = $final_unit_price;
|
||||||
|
}
|
||||||
|
|
||||||
|
$has_discount = $quantity > 0.0
|
||||||
|
&& $final_unit_price > 0.0
|
||||||
|
&& $original_unit_price > 0.0
|
||||||
|
&& $final_unit_price < ($original_unit_price - 0.00001);
|
||||||
|
|
||||||
|
$discount_unit_amount = $has_discount
|
||||||
|
? round($original_unit_price - $final_unit_price, 2)
|
||||||
|
: 0.0;
|
||||||
|
$discount_total_amount = round($discount_unit_amount * $quantity, 2);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'original_unit_price' => $original_unit_price,
|
||||||
|
'final_unit_price' => $final_unit_price,
|
||||||
|
'invoice_unit_price' => $has_discount ? $original_unit_price : $final_unit_price,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'discount_unit_amount' => $discount_unit_amount,
|
||||||
|
'discount_total_amount' => $discount_total_amount,
|
||||||
|
'discount_percentage' => $has_discount
|
||||||
|
? round((1 - ($final_unit_price / $original_unit_price)) * 100, 10)
|
||||||
|
: 0.0,
|
||||||
|
'has_discount' => $has_discount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function orderItemHasBillableDiscount(array $order_item): bool
|
||||||
|
{
|
||||||
|
return self::resolveOrderItemInvoicePricing($order_item)['has_discount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the hidden e-conomic percentage from converted amounts so the rendered monetary discount stays exact.
|
||||||
|
*
|
||||||
|
* @param array{invoice_unit_price:float,final_unit_price:float,has_discount:bool,discount_percentage:float} $pricing
|
||||||
|
*/
|
||||||
|
private function resolveItemizedDiscountPercentageForInvoiceCurrency(array $pricing): float
|
||||||
|
{
|
||||||
|
if (!$pricing['has_discount']) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$converted_original = self::convertCurrency($pricing['invoice_unit_price']);
|
||||||
|
$converted_final = self::convertCurrency($pricing['final_unit_price']);
|
||||||
|
if ($converted_original <= 0.0 || $converted_final <= 0.0) {
|
||||||
|
return $pricing['discount_percentage'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return round((1 - ($converted_final / $converted_original)) * 100, 10);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a product line to the draft invoice
|
* Add a product line to the draft invoice
|
||||||
* @note The lines won't be saved until the addLines() method is called.
|
* @note The lines won't be saved until the addLines() method is called.
|
||||||
*/
|
*/
|
||||||
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension): void
|
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
|
||||||
{
|
{
|
||||||
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
|
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
|
||||||
// Add a line to the invoice
|
// Add a line to the invoice
|
||||||
@@ -392,7 +478,7 @@ class economic_invoice_draft
|
|||||||
],
|
],
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'unitNetPrice' => self::convertCurrency($unitNetPrice),
|
'unitNetPrice' => self::convertCurrency($unitNetPrice),
|
||||||
'discountPercentage' => 0,
|
'discountPercentage' => $discountPercentage,
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
];
|
];
|
||||||
// If the department is set, add it to the line
|
// If the department is set, add it to the line
|
||||||
|
|||||||
@@ -799,7 +799,6 @@ class edge_gateway_manager
|
|||||||
$existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
|
$existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
|
||||||
'gateway_id' => $gatewayId,
|
'gateway_id' => $gatewayId,
|
||||||
'relay_id' => $relayId,
|
'relay_id' => $relayId,
|
||||||
'deleted_at' => null,
|
|
||||||
], ['id']);
|
], ['id']);
|
||||||
|
|
||||||
if ($existing !== []) {
|
if ($existing !== []) {
|
||||||
@@ -818,6 +817,7 @@ class edge_gateway_manager
|
|||||||
$bindingObject->approved_by->set($userId);
|
$bindingObject->approved_by->set($userId);
|
||||||
$bindingObject->approved_at->set($this->now());
|
$bindingObject->approved_at->set($this->now());
|
||||||
$bindingObject->metadata_json->set($bindingMetadata);
|
$bindingObject->metadata_json->set($bindingMetadata);
|
||||||
|
$bindingObject->deleted_at->set(null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
+16
-7
@@ -22,12 +22,21 @@ class licenseplaterecognizer_info
|
|||||||
public array $webhooks;
|
public array $webhooks;
|
||||||
public int $total_calls;
|
public int $total_calls;
|
||||||
|
|
||||||
public function __construct($data)
|
public function __construct(mixed $data)
|
||||||
{
|
{
|
||||||
$this->version = $data->version;
|
$values = is_array($data) ? $data : (is_object($data) ? get_object_vars($data) : []);
|
||||||
$this->usage = $data->usage ?? (object) ['calls' => 0];
|
$usage = $values['usage'] ?? (object)['calls' => 0];
|
||||||
$this->license_key = $data->license_key;
|
if (is_array($usage)) {
|
||||||
$this->webhooks = $data->webhooks;
|
$usage = (object)$usage;
|
||||||
$this->total_calls = $data->total_calls;
|
} elseif (!is_object($usage)) {
|
||||||
|
$usage = (object)['calls' => 0];
|
||||||
|
}
|
||||||
|
$usage->calls = is_numeric($usage->calls ?? null) ? (int)$usage->calls : 0;
|
||||||
|
|
||||||
|
$this->version = (string)($values['version'] ?? '');
|
||||||
|
$this->usage = $usage;
|
||||||
|
$this->license_key = (string)($values['license_key'] ?? '');
|
||||||
|
$this->webhooks = is_array($values['webhooks'] ?? null) ? $values['webhooks'] : [];
|
||||||
|
$this->total_calls = is_numeric($values['total_calls'] ?? null) ? (int)$values['total_calls'] : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ require_once WD . '/objects/selfserve_wash_session_answers_o.php';
|
|||||||
require_once WD . '/objects/selfserve_wash_session_events_o.php';
|
require_once WD . '/objects/selfserve_wash_session_events_o.php';
|
||||||
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
|
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
|
||||||
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||||
|
require_once WD . '/objects/subusers_o.php';
|
||||||
|
|
||||||
use classes\selfserve;
|
use classes\selfserve;
|
||||||
use classes\selfserve_schema_bootstrap;
|
use classes\selfserve_schema_bootstrap;
|
||||||
@@ -63,6 +64,7 @@ use objects\selfserve_wash_session_answers_o;
|
|||||||
use objects\selfserve_wash_session_events_o;
|
use objects\selfserve_wash_session_events_o;
|
||||||
use objects\selfserve_wash_session_tasks_o;
|
use objects\selfserve_wash_session_tasks_o;
|
||||||
use objects\selfserve_wash_sessions_o;
|
use objects\selfserve_wash_sessions_o;
|
||||||
|
use objects\subusers_o;
|
||||||
|
|
||||||
class selfserve_wash_flow implements selfserve_wash_flow_i
|
class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||||
{
|
{
|
||||||
@@ -76,7 +78,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
|
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
|
||||||
{
|
{
|
||||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||||
|
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||||
|
|
||||||
return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
||||||
}
|
}
|
||||||
@@ -89,7 +92,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
{
|
{
|
||||||
$options['debug'] = true;
|
$options['debug'] = true;
|
||||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||||
|
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||||
$response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
$response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
||||||
$response['simulator_version'] = 2;
|
$response['simulator_version'] = 2;
|
||||||
$response['dry_run'] = true;
|
$response['dry_run'] = true;
|
||||||
@@ -103,12 +107,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
||||||
{
|
{
|
||||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||||
|
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||||
$mutationResult = $this->withSessionMutationLock(
|
$mutationResult = $this->withSessionMutationLock(
|
||||||
$laneId,
|
$laneId,
|
||||||
$snapshot['reg'],
|
$snapshot['reg'],
|
||||||
$snapshot['customer_number'],
|
$snapshot['customer_number'],
|
||||||
function () use ($laneId, $snapshot, $options): array {
|
$actorSubuserId,
|
||||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
function () use ($laneId, $snapshot, $options, $actorSubuserId): array {
|
||||||
|
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||||
$createSession = (bool)($options['create_session'] ?? true);
|
$createSession = (bool)($options['create_session'] ?? true);
|
||||||
|
|
||||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||||
@@ -139,10 +145,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$this->deriveBaseStatus($snapshot),
|
$this->deriveBaseStatus($snapshot),
|
||||||
(bool)$snapshot['allowed'],
|
(bool)$snapshot['allowed'],
|
||||||
$this->buildSessionMetadata($snapshot),
|
$this->buildSessionMetadata($snapshot),
|
||||||
|
$actorSubuserId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||||
$session->customer_number->set($snapshot['customer_number']);
|
$session->customer_number->set($snapshot['customer_number']);
|
||||||
|
if ($actorSubuserId !== null) {
|
||||||
|
$currentSubuserId = $this->nullableInt($session->subuser_id->value());
|
||||||
|
if ($currentSubuserId === null || $currentSubuserId === $actorSubuserId) {
|
||||||
|
$session->subuser_id->set($actorSubuserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||||
$session->reg->set($snapshot['reg']);
|
$session->reg->set($snapshot['reg']);
|
||||||
@@ -158,6 +171,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||||
'allowed_services' => $snapshot['allowed_services'],
|
'allowed_services' => $snapshot['allowed_services'],
|
||||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||||
|
'subuser_id' => $actorSubuserId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -202,6 +216,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$lane = (new selfserve())->lane($laneId);
|
$lane = (new selfserve())->lane($laneId);
|
||||||
$effectiveReg = $normalizedReg ?? (string)$session->reg->value();
|
$effectiveReg = $normalizedReg ?? (string)$session->reg->value();
|
||||||
$customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
|
$customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
|
||||||
|
$subuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||||
|
|
||||||
if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) {
|
if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) {
|
||||||
$lane->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
$lane->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||||
@@ -227,11 +242,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'reg' => $effectiveReg,
|
'reg' => $effectiveReg,
|
||||||
'customer_number' => $customerNumber,
|
'customer_number' => $customerNumber,
|
||||||
|
'subuser_id' => $subuserId,
|
||||||
]);
|
]);
|
||||||
$actionContext = [
|
$actionContext = [
|
||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'reg' => $effectiveReg,
|
'reg' => $effectiveReg,
|
||||||
'customer_number' => $customerNumber,
|
'customer_number' => $customerNumber,
|
||||||
|
'subuser_id' => $subuserId,
|
||||||
'session_id' => (int)$session->id,
|
'session_id' => (int)$session->id,
|
||||||
'source_payload' => $payload,
|
'source_payload' => $payload,
|
||||||
];
|
];
|
||||||
@@ -269,12 +286,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool
|
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $subuserId = null): bool
|
||||||
{
|
{
|
||||||
$normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
|
$normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
|
||||||
$session = $normalizedReg !== null
|
$session = $normalizedReg !== null
|
||||||
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber)
|
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber, $subuserId)
|
||||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
|
||||||
|
|
||||||
return $session->exists() && (bool)$session->machine_start_triggered->value();
|
return $session->exists() && (bool)$session->machine_start_triggered->value();
|
||||||
}
|
}
|
||||||
@@ -403,10 +420,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
];
|
];
|
||||||
}, (new selfserve_wash_session_events_o())->listBySession($sessionId));
|
}, (new selfserve_wash_session_events_o())->listBySession($sessionId));
|
||||||
|
|
||||||
|
$sessionPayload = $session->asArray();
|
||||||
|
$sessionPayload['subuser'] = $this->formatSessionSubuser($this->nullableInt($session->subuser_id->value()));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'session' => $session->asArray(),
|
'session' => $sessionPayload,
|
||||||
'lane' => $lane->exists() ? $lane->asArray() : null,
|
'lane' => $lane->exists() ? $lane->asArray() : null,
|
||||||
'machine_type' => $machineType,
|
'machine_type' => $machineType,
|
||||||
|
'subuser' => $sessionPayload['subuser'],
|
||||||
'questions' => $answers,
|
'questions' => $answers,
|
||||||
'tasks' => $tasks,
|
'tasks' => $tasks,
|
||||||
'events' => $events,
|
'events' => $events,
|
||||||
@@ -430,15 +451,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array
|
||||||
{
|
{
|
||||||
$session = $reg !== null
|
$session = $reg !== null
|
||||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber, $subuserId)
|
||||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
|
||||||
|
|
||||||
if (!$session->exists()) {
|
if (!$session->exists()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
$resolvedSubuserId = $subuserId ?? $this->nullableInt($session->subuser_id->value());
|
||||||
|
|
||||||
|
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
|
||||||
|
|
||||||
if (!$session->markCompletedIfOpen($orderId)) {
|
if (!$session->markCompletedIfOpen($orderId)) {
|
||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
@@ -450,12 +474,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||||
'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()),
|
'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()),
|
||||||
|
'subuser_id' => $resolvedSubuserId,
|
||||||
'order_id' => $orderId,
|
'order_id' => $orderId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($session->wash_started_at->value() !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lane = (new selfserve())->lane($laneId);
|
||||||
|
$washStartedAt = (int)$lane->getWashStartTime();
|
||||||
|
if ($washStartedAt <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt));
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Session timestamp enrichment must not block STOP completion.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
|
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
|
||||||
{
|
{
|
||||||
$lane = (new selfserve())->lane($laneId);
|
$lane = (new selfserve())->lane($laneId);
|
||||||
@@ -534,6 +578,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'vehicle' => $vehicleData,
|
'vehicle' => $vehicleData,
|
||||||
'reg' => $normalizedReg,
|
'reg' => $normalizedReg,
|
||||||
'customer_number' => $resolvedCustomerNumber,
|
'customer_number' => $resolvedCustomerNumber,
|
||||||
|
'subuser_id' => $this->resolveActorSubuserId($options),
|
||||||
'vehicle_type_id' => $vehicleTypeId,
|
'vehicle_type_id' => $vehicleTypeId,
|
||||||
'answers' => [],
|
'answers' => [],
|
||||||
'persisted_answers' => [],
|
'persisted_answers' => [],
|
||||||
@@ -725,6 +770,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'vehicle' => $vehicleData,
|
'vehicle' => $vehicleData,
|
||||||
'reg' => $normalizedReg,
|
'reg' => $normalizedReg,
|
||||||
'customer_number' => $resolvedCustomerNumber,
|
'customer_number' => $resolvedCustomerNumber,
|
||||||
|
'subuser_id' => $this->resolveActorSubuserId($options),
|
||||||
'vehicle_type_id' => $vehicleTypeId,
|
'vehicle_type_id' => $vehicleTypeId,
|
||||||
'answers' => $answers,
|
'answers' => $answers,
|
||||||
'persisted_answers' => $persistedAnswers,
|
'persisted_answers' => $persistedAnswers,
|
||||||
@@ -833,6 +879,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'vehicle' => $snapshot['vehicle'],
|
'vehicle' => $snapshot['vehicle'],
|
||||||
'reg' => $snapshot['reg'],
|
'reg' => $snapshot['reg'],
|
||||||
'customer_number' => $snapshot['customer_number'],
|
'customer_number' => $snapshot['customer_number'],
|
||||||
|
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||||
'vehicle_type_id' => $snapshot['vehicle_type_id'],
|
'vehicle_type_id' => $snapshot['vehicle_type_id'],
|
||||||
'questions' => $snapshot['questions'],
|
'questions' => $snapshot['questions'],
|
||||||
'tasks' => $snapshot['tasks'],
|
'tasks' => $snapshot['tasks'],
|
||||||
@@ -855,6 +902,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'session' => null,
|
'session' => null,
|
||||||
'lane' => $snapshot['lane'],
|
'lane' => $snapshot['lane'],
|
||||||
'machine_type' => $snapshot['machine_type'],
|
'machine_type' => $snapshot['machine_type'],
|
||||||
|
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||||
'questions' => [],
|
'questions' => [],
|
||||||
'tasks' => [],
|
'tasks' => [],
|
||||||
'events' => [],
|
'events' => [],
|
||||||
@@ -3238,13 +3286,47 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $options
|
||||||
|
*/
|
||||||
|
protected function resolveActorSubuserId(array $options): ?int
|
||||||
|
{
|
||||||
|
return $this->nullableInt($options['subuser_id'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatSessionSubuser(?int $subuserId): ?array
|
||||||
|
{
|
||||||
|
if ($subuserId === null || $subuserId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$subuser = (new subusers_o())->select($subuserId);
|
||||||
|
if (!$subuser->exists()) {
|
||||||
|
return [
|
||||||
|
'id' => $subuserId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)$subuser->id,
|
||||||
|
'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(),
|
||||||
|
'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(),
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [
|
||||||
|
'id' => $subuserId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param callable():array<string,mixed> $callback
|
* @param callable():array<string,mixed> $callback
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
|
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId, callable $callback): array
|
||||||
{
|
{
|
||||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
|
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber, $subuserId);
|
||||||
$lock = $this->acquireSessionMutationLock($lockKey);
|
$lock = $this->acquireSessionMutationLock($lockKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3311,10 +3393,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
|
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId = null): string
|
||||||
{
|
{
|
||||||
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
|
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
|
||||||
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
selfserve::standardize_registration($reg)
|
||||||
|
. ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
||||||
|
. ':' . ($subuserId === null ? 'customer' : 'subuser:' . (int)$subuserId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3324,6 +3408,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'allowed_services' => $snapshot['allowed_services'],
|
'allowed_services' => $snapshot['allowed_services'],
|
||||||
'machine_available' => (bool)$snapshot['machine_available'],
|
'machine_available' => (bool)$snapshot['machine_available'],
|
||||||
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
|
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
|
||||||
|
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||||
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
||||||
@@ -3474,14 +3559,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $vehicleTypeId > 0 ? $vehicleTypeId : null;
|
return $vehicleTypeId > 0 ? $vehicleTypeId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o
|
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
|
||||||
{
|
{
|
||||||
$session = new selfserve_wash_sessions_o();
|
$session = new selfserve_wash_sessions_o();
|
||||||
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber);
|
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber, $subuserId);
|
||||||
return $session;
|
return $session;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o
|
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
|
||||||
{
|
{
|
||||||
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere(
|
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere(
|
||||||
[
|
[
|
||||||
@@ -3489,6 +3574,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'completed_at' => null,
|
'completed_at' => null,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
|
...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
|
||||||
|
...($subuserId !== null ? ['subuser_id' => $subuserId] : []),
|
||||||
],
|
],
|
||||||
['id', 'status']
|
['id', 'status']
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'selfserve.activate_opening_cleaner_relays',
|
||||||
|
'legacy_name' => 'SelfserveOpeningRelayActivationCron',
|
||||||
|
'name' => 'Activate self-serve opening cleaner relays',
|
||||||
|
'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.',
|
||||||
|
'module' => 'selfserve',
|
||||||
|
'handler' => 'SelfserveOpeningRelayActivationCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||||
|
'timeout_seconds' => 300,
|
||||||
|
'estimated_duration_ms' => 5000,
|
||||||
|
'priority' => 45,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
|||||||
|
|
||||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array;
|
||||||
|
|
||||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,8 +356,8 @@ Purpose: operational lane control and relay management.
|
|||||||
| Method | Required params | Permissions | Notes |
|
| Method | Required params | Permissions | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, customer `add_own_department_selfserve_vehicle_conditions` for scoped `START`, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `STOP` and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `add_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||||
@@ -439,7 +439,7 @@ Public methods:
|
|||||||
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
||||||
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
||||||
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
||||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
|
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. Driver flows pass `subuserId` so same-customer drivers close only their own session. | Full summary, or `null` if no open session exists. |
|
||||||
|
|
||||||
Key implementation details:
|
Key implementation details:
|
||||||
|
|
||||||
|
|||||||
@@ -120,14 +120,15 @@ trait selfserve_lane_command_t
|
|||||||
/**
|
/**
|
||||||
* Machine-wash billing is based on the physical machine ON signal, not selector relay status.
|
* Machine-wash billing is based on the physical machine ON signal, not selector relay status.
|
||||||
*/
|
*/
|
||||||
protected function hasMachineStartSignalForStop(): bool
|
protected function hasMachineStartSignalForStop(?int $subuser_id = null): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
|
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
|
||||||
return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane(
|
return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane(
|
||||||
(int)$this->id,
|
(int)$this->id,
|
||||||
method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null,
|
method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null,
|
||||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
|
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
|
||||||
|
$subuser_id
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return false;
|
return false;
|
||||||
@@ -240,7 +241,9 @@ trait selfserve_lane_command_t
|
|||||||
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
|
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
|
||||||
(int)$this->id,
|
(int)$this->id,
|
||||||
$reg,
|
$reg,
|
||||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
|
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
|
||||||
|
null,
|
||||||
|
['subuser_id' => $arguments?->subuser_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (bool)($snapshot['allowed'] ?? false);
|
return (bool)($snapshot['allowed'] ?? false);
|
||||||
@@ -531,7 +534,7 @@ trait selfserve_lane_command_t
|
|||||||
/**
|
/**
|
||||||
* Finalize any active self-serve wash session before resetting lane state.
|
* Finalize any active self-serve wash session before resetting lane state.
|
||||||
*/
|
*/
|
||||||
protected function completeLatestSessionForStop(): void
|
protected function completeLatestSessionForStop(?int $subuser_id = null): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
|
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
|
||||||
@@ -539,7 +542,8 @@ trait selfserve_lane_command_t
|
|||||||
$this->getLicensePlate() ?: null,
|
$this->getLicensePlate() ?: null,
|
||||||
$this->getCustomerNumber() ?: null,
|
$this->getCustomerNumber() ?: null,
|
||||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||||
false
|
false,
|
||||||
|
$subuser_id
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Session completion must not block STOP flow.
|
// Session completion must not block STOP flow.
|
||||||
@@ -711,7 +715,7 @@ trait selfserve_lane_command_t
|
|||||||
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
|
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
|
||||||
}
|
}
|
||||||
// Snapshot the physical machine ON signal before session completion/reset.
|
// Snapshot the physical machine ON signal before session completion/reset.
|
||||||
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
$machine_start_triggered = $this->hasMachineStartSignalForStop($arguments->subuser_id);
|
||||||
// Turn off relays before any configured or default exit gate opens.
|
// Turn off relays before any configured or default exit gate opens.
|
||||||
$this->turnOffRelaysAfterStop();
|
$this->turnOffRelaysAfterStop();
|
||||||
$this->runPublishedStudioActions(
|
$this->runPublishedStudioActions(
|
||||||
@@ -733,7 +737,7 @@ trait selfserve_lane_command_t
|
|||||||
// Only bill the machine wash product when the physical machine start signal was recorded.
|
// Only bill the machine wash product when the physical machine start signal was recorded.
|
||||||
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
||||||
// Finalize any active self-serve wash session for this lane
|
// Finalize any active self-serve wash session for this lane
|
||||||
$this->completeLatestSessionForStop();
|
$this->completeLatestSessionForStop($arguments->subuser_id);
|
||||||
// Reset the lane
|
// Reset the lane
|
||||||
self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -260,8 +260,9 @@ trait selfserve_lane_invoice_t
|
|||||||
?int $draft_customer_number,
|
?int $draft_customer_number,
|
||||||
?selfserve_lane_command_arguments $arguments = null
|
?selfserve_lane_command_arguments $arguments = null
|
||||||
): array {
|
): array {
|
||||||
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
|
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number, $arguments?->subuser_id);
|
||||||
$subuser_id = $arguments?->subuser_id;
|
$session_subuser_id = $session?->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||||
|
$subuser_id = $arguments?->subuser_id ?? $session_subuser_id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
|
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
|
||||||
@@ -282,7 +283,7 @@ trait selfserve_lane_invoice_t
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
|
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number, ?int $subuser_id = null): ?selfserve_wash_sessions_o
|
||||||
{
|
{
|
||||||
$license_plate = trim((string)$this->getLicensePlate());
|
$license_plate = trim((string)$this->getLicensePlate());
|
||||||
if ($license_plate === '') {
|
if ($license_plate === '') {
|
||||||
@@ -290,12 +291,31 @@ trait selfserve_lane_invoice_t
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||||
|
(int)$this->id,
|
||||||
|
selfserve::standardize_registration($license_plate),
|
||||||
|
$billing_customer_number > 0 ? $billing_customer_number : null,
|
||||||
|
$subuser_id
|
||||||
|
);
|
||||||
|
if ($session->exists()) {
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subuser_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||||
(int)$this->id,
|
(int)$this->id,
|
||||||
selfserve::standardize_registration($license_plate),
|
selfserve::standardize_registration($license_plate),
|
||||||
$billing_customer_number > 0 ? $billing_customer_number : null
|
$billing_customer_number > 0 ? $billing_customer_number : null
|
||||||
);
|
);
|
||||||
return $session->exists() ? $session : null;
|
if (!$session->exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionSubuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||||
|
return $sessionSubuserId === null || $sessionSubuserId === $subuser_id ? $session : null;
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'system.sync_logs',
|
||||||
|
'legacy_name' => 'SyncLogs',
|
||||||
|
'name' => 'Sync logs',
|
||||||
|
'description' => 'Flush Redis-backed application logs into the database.',
|
||||||
|
'module' => 'system',
|
||||||
|
'handler' => 'syncLogsToDatabase',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||||
|
'timeout_seconds' => 120,
|
||||||
|
'estimated_duration_ms' => 1000,
|
||||||
|
'priority' => 10,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'system.search_cache_maintenance',
|
||||||
|
'legacy_name' => 'SystemSearchCacheMaintenanceCron',
|
||||||
|
'name' => 'System search cache maintenance',
|
||||||
|
'description' => 'Processes dirty-table and full rebuild requests for system search caches.',
|
||||||
|
'module' => 'system',
|
||||||
|
'handler' => 'SystemSearchCacheMaintenanceCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||||
|
'timeout_seconds' => 600,
|
||||||
|
'estimated_duration_ms' => 3000,
|
||||||
|
'priority' => 70,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'system.prune_session_activity',
|
||||||
|
'legacy_name' => 'PruneSystemSessionActivityCron',
|
||||||
|
'name' => 'Prune system session activity',
|
||||||
|
'description' => 'Deletes stale system session activity rows.',
|
||||||
|
'module' => 'system',
|
||||||
|
'handler' => 'PruneSystemSessionActivityCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||||
|
'timeout_seconds' => 300,
|
||||||
|
'estimated_duration_ms' => 1000,
|
||||||
|
'priority' => 120,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'system.invoice_flags_manual_cache',
|
||||||
|
'legacy_name' => 'WarmInvoicePeriodManualFlagsCron',
|
||||||
|
'name' => 'Warm manual invoice flag cache',
|
||||||
|
'description' => 'Warms superuser invoice-period manual flag counters.',
|
||||||
|
'module' => 'system',
|
||||||
|
'handler' => 'WarmInvoicePeriodManualFlagsCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||||
|
'timeout_seconds' => 600,
|
||||||
|
'estimated_duration_ms' => 2000,
|
||||||
|
'priority' => 80,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'system.invoice_flags_automatic_cache',
|
||||||
|
'legacy_name' => 'WarmInvoicePeriodAutomaticFlagsCron',
|
||||||
|
'name' => 'Warm automatic invoice flag cache',
|
||||||
|
'description' => 'Warms current, previous, and queued invoice-period automatic flag caches.',
|
||||||
|
'module' => 'system',
|
||||||
|
'handler' => 'WarmInvoicePeriodAutomaticFlagsCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||||
|
'timeout_seconds' => 900,
|
||||||
|
'estimated_duration_ms' => 5000,
|
||||||
|
'priority' => 85,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'weatherapi.preload_department_responses',
|
||||||
|
'legacy_name' => 'PreloadDepartmentWeatherResponsesCron',
|
||||||
|
'name' => 'Preload department weather responses',
|
||||||
|
'description' => 'Warms department weather timeline responses for visible and active departments.',
|
||||||
|
'module' => 'weatherapi',
|
||||||
|
'handler' => 'PreloadDepartmentWeatherResponsesCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||||
|
'timeout_seconds' => 300,
|
||||||
|
'estimated_duration_ms' => 5000,
|
||||||
|
'priority' => 40,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'workfeed.warm_employee_names',
|
||||||
|
'legacy_name' => 'WarmWorkfeedEmployeeNamesCron',
|
||||||
|
'name' => 'Warm Workfeed employee names',
|
||||||
|
'description' => 'Warms Redis mappings for Workfeed employee identifiers and display names.',
|
||||||
|
'module' => 'workfeed',
|
||||||
|
'handler' => 'WarmWorkfeedEmployeeNamesCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 21600],
|
||||||
|
'timeout_seconds' => 600,
|
||||||
|
'estimated_duration_ms' => 5000,
|
||||||
|
'priority' => 100,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'xlvask.sync_module',
|
||||||
|
'legacy_name' => 'SyncXLVaskModuleCron',
|
||||||
|
'name' => 'Sync XL Vask module',
|
||||||
|
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
|
||||||
|
'module' => 'xlvask',
|
||||||
|
'handler' => 'SyncXLVaskModuleCron',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 3600],
|
||||||
|
'timeout_seconds' => 900,
|
||||||
|
'estimated_duration_ms' => 10000,
|
||||||
|
'priority' => 95,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -10,6 +10,7 @@ use config\economic_admin_fee_monthly_c;
|
|||||||
use config\economic_admin_fee_order_c;
|
use config\economic_admin_fee_order_c;
|
||||||
use config\economic_fee_product_id_c;
|
use config\economic_fee_product_id_c;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use helpers\economic_invoice_draft;
|
||||||
use objects\order_items_o;
|
use objects\order_items_o;
|
||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
@@ -427,7 +428,8 @@ class collected_order_invoices_o extends db
|
|||||||
if (empty($this->customer_number->value())) {
|
if (empty($this->customer_number->value())) {
|
||||||
throw new Exception('Customer number is not set');
|
throw new Exception('Customer number is not set');
|
||||||
}
|
}
|
||||||
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
$economic = new economic();
|
||||||
|
$economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||||
if (!$ignore_closed) {
|
if (!$ignore_closed) {
|
||||||
// Require the invoice collection to be open
|
// Require the invoice collection to be open
|
||||||
self::requireOpen();
|
self::requireOpen();
|
||||||
@@ -481,7 +483,8 @@ class collected_order_invoices_o extends db
|
|||||||
{
|
{
|
||||||
// Require the invoice collection to be selected
|
// Require the invoice collection to be selected
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
$economic = new economic();
|
||||||
|
$economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||||
// Check if the invoice draft already exists
|
// Check if the invoice draft already exists
|
||||||
if (self::isDraftExisting() || self::isBooked()) {
|
if (self::isDraftExisting() || self::isBooked()) {
|
||||||
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
|
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
|
||||||
@@ -497,11 +500,12 @@ class collected_order_invoices_o extends db
|
|||||||
// Convert the date to the correct format
|
// Convert the date to the correct format
|
||||||
$date = date('Y-m-d', strtotime($date));
|
$date = date('Y-m-d', strtotime($date));
|
||||||
// Create the invoice draft
|
// Create the invoice draft
|
||||||
$economic = new economic();
|
$layout_number = $this->resolveInvoiceLayoutNumber($economic);
|
||||||
$response = $economic->invoices->drafts->add(
|
$response = $economic->invoices->drafts->add(
|
||||||
$this->customer_number->value(),
|
$this->customer_number->value(),
|
||||||
self::getExternalId(),
|
self::getExternalId(),
|
||||||
$date
|
$date,
|
||||||
|
$layout_number
|
||||||
);
|
);
|
||||||
// Validate the response, by checking if the external id is set
|
// Validate the response, by checking if the external id is set
|
||||||
if (empty($response->references->other)) {
|
if (empty($response->references->other)) {
|
||||||
@@ -515,6 +519,70 @@ class collected_order_invoices_o extends db
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the e-conomic layout before draft creation.
|
||||||
|
*
|
||||||
|
* The default layout is the current non-discount layout. The discount layout
|
||||||
|
* must be the manually duplicated e-conomic layout configured to show exact
|
||||||
|
* monetary discounts in the Rabat column.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function resolveInvoiceLayoutNumber(economic $economic): int
|
||||||
|
{
|
||||||
|
if (!$this->hasDiscountedIncludedInvoiceItems()) {
|
||||||
|
return (int)$economic->config->invoice_layout->getVariableValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
$layout_number = (int)$economic->config->invoice_discount_layout->getVariableValue();
|
||||||
|
if ($layout_number <= 0) {
|
||||||
|
throw new Exception('Discount invoice layout is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $layout_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether any billable included product line should use the discount invoice layout.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function hasDiscountedIncludedInvoiceItems(): bool
|
||||||
|
{
|
||||||
|
foreach ( self::getOrders() as $order ) {
|
||||||
|
$order_object = new orders_o();
|
||||||
|
$order_object->select((int)$order['id']);
|
||||||
|
$order_object->requireSelected();
|
||||||
|
if (self::orderHasDiscountedIncludedInvoiceItems($order_object)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private static function orderHasDiscountedIncludedInvoiceItems(orders_o $order): bool
|
||||||
|
{
|
||||||
|
$order_items = $order->applyDepartmentPrices(
|
||||||
|
$order->getOrderItems((int)$order->id),
|
||||||
|
(int)$order->department_id->value()
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ( $order_items as $order_item ) {
|
||||||
|
if (empty($order_item['include_in_invoice'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (economic_invoice_draft::orderItemHasBillableDiscount($order_item)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Require the invoice draft to not already exist
|
* Require the invoice draft to not already exist
|
||||||
* @throws Exception If the request was not successful
|
* @throws Exception If the request was not successful
|
||||||
@@ -746,7 +814,14 @@ class collected_order_invoices_o extends db
|
|||||||
$order_object->requireSelected();
|
$order_object->requireSelected();
|
||||||
$order_objects[] = $order_object;
|
$order_objects[] = $order_object;
|
||||||
}
|
}
|
||||||
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
|
$use_itemized_discounts = false;
|
||||||
|
foreach ( $order_objects as $order_object ) {
|
||||||
|
if (self::orderHasDiscountedIncludedInvoiceItems($order_object)) {
|
||||||
|
$use_itemized_discounts = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);
|
||||||
$this->last_economic_transfer_metrics = [
|
$this->last_economic_transfer_metrics = [
|
||||||
'draft_invoice_id' => $draft_id,
|
'draft_invoice_id' => $draft_id,
|
||||||
'currency' => (string)$currency,
|
'currency' => (string)$currency,
|
||||||
|
|||||||
@@ -490,18 +490,19 @@ class customer_vehicles_o extends db
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
//print_r($transaction_ids);
|
//print_r($transaction_ids);
|
||||||
// Convert the array of transaction ids to a comma separated string
|
$orders = [];
|
||||||
$orders = '';
|
|
||||||
foreach ($transaction_ids as $transaction_id) {
|
foreach ($transaction_ids as $transaction_id) {
|
||||||
// Check if the transaction is included in the invoicing.
|
// Check if the transaction is included in the invoicing.
|
||||||
$tmp = (new orders_o())->select((int)$transaction_id);
|
$tmp = (new orders_o())->select((int)$transaction_id);
|
||||||
if (!$tmp->isIncludedInInvoicing()) {
|
if (!$tmp->isIncludedInInvoicing()) {
|
||||||
continue; // The transaction is not included in the invoicing, skip it
|
continue; // The transaction is not included in the invoicing, skip it
|
||||||
}
|
}
|
||||||
$orders .= (int)$transaction_id . ',';
|
$orders[] = (int)$transaction_id;
|
||||||
}
|
}
|
||||||
// Remove the last comma
|
if (empty($orders)) {
|
||||||
$orders = rtrim($orders, ',');
|
return [];
|
||||||
|
}
|
||||||
|
$order_ids = implode(',', $orders);
|
||||||
// Get the first two transactions that are not deleted and contains at least one order item with the 'product_id' of the vehicle type for the vehicle
|
// Get the first two transactions that are not deleted and contains at least one order item with the 'product_id' of the vehicle type for the vehicle
|
||||||
$query = "
|
$query = "
|
||||||
SELECT o.id
|
SELECT o.id
|
||||||
@@ -509,7 +510,7 @@ class customer_vehicles_o extends db
|
|||||||
JOIN order_items oi ON oi.order_id = o.id
|
JOIN order_items oi ON oi.order_id = o.id
|
||||||
WHERE o.deleted_at IS NULL
|
WHERE o.deleted_at IS NULL
|
||||||
AND oi.deleted_at IS NULL
|
AND oi.deleted_at IS NULL
|
||||||
AND o.id IN ($orders)
|
AND o.id IN ($order_ids)
|
||||||
AND oi.product_id = " . (int)$this->type->value() . "
|
AND oi.product_id = " . (int)$this->type->value() . "
|
||||||
GROUP BY o.id
|
GROUP BY o.id
|
||||||
ORDER BY o.created_at ASC
|
ORDER BY o.created_at ASC
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace objects;
|
||||||
|
|
||||||
|
use classes\db;
|
||||||
|
use classes\department_customer_price_overrides_schema_bootstrap;
|
||||||
|
use traits\db_object_t;
|
||||||
|
|
||||||
|
class department_customer_price_overrides_o extends db
|
||||||
|
{
|
||||||
|
use db_object_t;
|
||||||
|
|
||||||
|
public function structure(): void
|
||||||
|
{
|
||||||
|
department_customer_price_overrides_schema_bootstrap::ensureTables();
|
||||||
|
$this->setTable('department_customer_price_overrides');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function objectChanged(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId, int $percentage, ?int $fixedPrice = null): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if ($isCategory) {
|
||||||
|
$fixedPrice = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removePrice($departmentId, $userId, $isCategory, $objectId);
|
||||||
|
|
||||||
|
if ($percentage <= 0 && $fixedPrice === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$objectId = $db->escape_string((string)$objectId);
|
||||||
|
$fixedPriceSql = $fixedPrice === null ? 'NULL' : (string)max(0, (int)$fixedPrice);
|
||||||
|
$sql = "INSERT INTO {$this->table} (`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
|
||||||
|
VALUES (" . (int)$departmentId . ", " . (int)$userId . ", " . (int)$isCategory . ", '{$objectId}', " . (int)$percentage . ", {$fixedPriceSql})";
|
||||||
|
$db->query($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removePrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$objectId = $db->escape_string((string)$objectId);
|
||||||
|
$sql = "DELETE FROM {$this->table}
|
||||||
|
WHERE `department_id` = " . (int)$departmentId . "
|
||||||
|
AND `user_id` = " . (int)$userId . "
|
||||||
|
AND `is_category` = " . (int)$isCategory . "
|
||||||
|
AND `product_or_category_id` = '{$objectId}'";
|
||||||
|
$db->query($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function getAllPrices(int $departmentId, int $userId): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$sql = "SELECT * FROM {$this->table}
|
||||||
|
WHERE `department_id` = " . (int)$departmentId . "
|
||||||
|
AND `user_id` = " . (int)$userId . "
|
||||||
|
ORDER BY `is_category` DESC, `product_or_category_id` ASC";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$prices = [];
|
||||||
|
if ($result && $result->num_rows > 0) {
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$prices[] = $this->parseRow($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDirectPriceRow(int $departmentId, int $userId, bool $isCategory, int|string $objectId): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$objectId = $db->escape_string((string)$objectId);
|
||||||
|
$sql = "SELECT * FROM {$this->table}
|
||||||
|
WHERE `department_id` = " . (int)$departmentId . "
|
||||||
|
AND `user_id` = " . (int)$userId . "
|
||||||
|
AND `is_category` = " . (int)$isCategory . "
|
||||||
|
AND `product_or_category_id` = '{$objectId}'
|
||||||
|
LIMIT 1";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result || $result->num_rows < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseRow($result->fetch_assoc());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseRow(array $row): array
|
||||||
|
{
|
||||||
|
$isCategory = (bool)$row['is_category'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'department_id' => (int)$row['department_id'],
|
||||||
|
'user_id' => (int)$row['user_id'],
|
||||||
|
'is_category' => $isCategory,
|
||||||
|
'product_or_category_id' => $isCategory ? (string)$row['product_or_category_id'] : (int)$row['product_or_category_id'],
|
||||||
|
'percentage' => (int)$row['percentage'],
|
||||||
|
'fixed_price' => array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null,
|
||||||
|
'created_at' => (string)$row['created_at'],
|
||||||
|
'updated_at' => (string)$row['updated_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
|
require_once WD . '/classes/department_wash_count_service.php';
|
||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\department_wash_count_service;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
@@ -510,8 +513,6 @@ class department_daily_reports_o extends db
|
|||||||
public function getTransactionsOnDateWashesCount(string $date, int $department_id, string $date_to = null): int
|
public function getTransactionsOnDateWashesCount(string $date, int $department_id, string $date_to = null): int
|
||||||
{
|
{
|
||||||
return self::methodCacheWithParameters(__METHOD__, func_get_args(), 120, function() use ($date, $department_id, $date_to) {
|
return self::methodCacheWithParameters(__METHOD__, func_get_args(), 120, function() use ($date, $department_id, $date_to) {
|
||||||
global /** @var db $db */
|
|
||||||
$db;
|
|
||||||
// If the date_to is null, set it to the date
|
// If the date_to is null, set it to the date
|
||||||
if ($date_to === null) {
|
if ($date_to === null) {
|
||||||
$date_to = $date; // Making the report for an entire day
|
$date_to = $date; // Making the report for an entire day
|
||||||
@@ -519,33 +520,7 @@ class department_daily_reports_o extends db
|
|||||||
// Set the date time to cover the entire day
|
// Set the date time to cover the entire day
|
||||||
$date = date('Y-m-d 00:00:00', strtotime($date));
|
$date = date('Y-m-d 00:00:00', strtotime($date));
|
||||||
$date_to = date('Y-m-d 23:59:59', strtotime($date_to));
|
$date_to = date('Y-m-d 23:59:59', strtotime($date_to));
|
||||||
$conn = $db->conn();
|
return (new department_wash_count_service())->countInDateRange($date, $date_to, $department_id);
|
||||||
// Get all the product prices, in all the orders (Not counting removed orders), for the given date and department, and sum them
|
|
||||||
$stmt = $conn->prepare(
|
|
||||||
'SELECT COUNT(DISTINCT o.id) as amount FROM orders o
|
|
||||||
JOIN order_items oi ON o.id = oi.order_id
|
|
||||||
JOIN products p ON oi.product_id = p.id
|
|
||||||
WHERE o.department_id = ? AND DATE(o.created_at) BETWEEN ? AND ? AND o.deleted_at IS NULL AND oi.deleted_at IS NULL AND p.is_wash = 1'
|
|
||||||
);
|
|
||||||
if ($stmt) {
|
|
||||||
$stmt->bind_param('iss', $department_id, $date, $date_to); // Bind parameters (i = integer, s = string)
|
|
||||||
$stmt->execute();
|
|
||||||
$result = $stmt->get_result(); // Get the result set from the statement
|
|
||||||
$data = $result->fetch_assoc(); // Fetch the result as an associative array
|
|
||||||
|
|
||||||
// Access the "amount" field
|
|
||||||
if (!$data) {
|
|
||||||
// If there are no orders, set the amount to 0
|
|
||||||
$amount = 0;
|
|
||||||
} else {
|
|
||||||
$amount = $data['amount'];
|
|
||||||
}
|
|
||||||
$stmt->close(); // Close the statement
|
|
||||||
} else {
|
|
||||||
// Handle query preparation error
|
|
||||||
die('Query preparation failed: ' . $conn->error);
|
|
||||||
}
|
|
||||||
return (int)$amount;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,9 +591,6 @@ class department_daily_reports_o extends db
|
|||||||
*/
|
*/
|
||||||
public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array
|
public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array
|
||||||
{
|
{
|
||||||
global /** @var db $db */
|
|
||||||
$db;
|
|
||||||
|
|
||||||
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
||||||
if ($normalized_department_ids === []) {
|
if ($normalized_department_ids === []) {
|
||||||
return [
|
return [
|
||||||
@@ -631,30 +603,17 @@ class department_daily_reports_o extends db
|
|||||||
}
|
}
|
||||||
|
|
||||||
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
|
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
|
||||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
$transaction_summary = (new department_wash_count_service())->transactionSummary(
|
||||||
$escaped_start = $db->escape_string($date_start);
|
$date_start,
|
||||||
$escaped_end = $db->escape_string($date_end);
|
$date_end,
|
||||||
|
$normalized_department_ids
|
||||||
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
|
);
|
||||||
COALESCE(SUM(oi.quantity), 0) AS products,
|
|
||||||
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings,
|
|
||||||
COUNT(DISTINCT CASE WHEN p.is_wash = 1 THEN o.id END) AS washes
|
|
||||||
FROM orders o
|
|
||||||
JOIN order_items oi ON oi.order_id = o.id
|
|
||||||
LEFT 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";
|
|
||||||
|
|
||||||
$result = $db->query($sql);
|
|
||||||
$row = is_object($result) ? $result->fetch_assoc() : null;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'quantity' => (int)($row['quantity'] ?? 0),
|
'quantity' => (int)($transaction_summary['quantity'] ?? 0),
|
||||||
'products' => (int)($row['products'] ?? 0),
|
'products' => (int)($transaction_summary['products'] ?? 0),
|
||||||
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
|
'earnings' => (int)($transaction_summary['earnings'] ?? 0),
|
||||||
'washes' => (int)($row['washes'] ?? 0),
|
'washes' => (int)($transaction_summary['washes'] ?? 0),
|
||||||
'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to),
|
'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -758,45 +717,13 @@ class department_daily_reports_o extends db
|
|||||||
*/
|
*/
|
||||||
public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array
|
public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array
|
||||||
{
|
{
|
||||||
global /** @var db $db */
|
|
||||||
$db;
|
|
||||||
|
|
||||||
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
||||||
if ($normalized_department_ids === []) {
|
if ($normalized_department_ids === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
|
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
|
||||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
return (new department_wash_count_service())->listTransactions($date_start, $date_end, $normalized_department_ids);
|
||||||
$escaped_start = $db->escape_string($date_start);
|
|
||||||
$escaped_end = $db->escape_string($date_end);
|
|
||||||
|
|
||||||
$sql = "SELECT DISTINCT o.id, o.department_id, o.created_at
|
|
||||||
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
|
|
||||||
ORDER BY o.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ class departments_o extends db
|
|||||||
public function isModuleTimeBookingsEnabled(): bool
|
public function isModuleTimeBookingsEnabled(): bool
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
return (bool)$this->variables->getVariable('bookingsystem_time_based_enabled');
|
return $this->variables->getVariable('bookingsystem_time_based_enabled') === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\module_usage_schema_bootstrap;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
@@ -40,6 +41,7 @@ class module_action_logs_o extends db
|
|||||||
|
|
||||||
public function structure(): void
|
public function structure(): void
|
||||||
{
|
{
|
||||||
|
module_usage_schema_bootstrap::ensureTables();
|
||||||
$this->setTable('module_usage_logs');
|
$this->setTable('module_usage_logs');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,4 +133,4 @@ class module_action_logs_o extends db
|
|||||||
|
|
||||||
return is_array($decoded) ? $decoded : [];
|
return is_array($decoded) ? $decoded : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ class order_bookings_o extends db
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
|
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
|
||||||
if ($order->hasWashCertificateAttached()) {
|
if ($this->getOrder()->hasWashCertificateAttached()) {
|
||||||
$this->sendWashCertificateToCustomer();
|
$this->sendWashCertificateToCustomer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,7 +585,7 @@ class order_bookings_o extends db
|
|||||||
return !empty($this->order_id->value());
|
return !empty($this->order_id->value());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, string $date = null): int
|
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, ?string $date = null): int
|
||||||
{
|
{
|
||||||
global /** @var db $db */
|
global /** @var db $db */
|
||||||
$db;
|
$db;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use classes\db;
|
|||||||
use classes\customer_order_product_policy;
|
use classes\customer_order_product_policy;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use RuntimeException;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
|
|
||||||
class order_items_o extends db
|
class order_items_o extends db
|
||||||
@@ -90,10 +91,42 @@ class order_items_o extends db
|
|||||||
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
|
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function validatedRelatedItemId(int $orderId, mixed $relatedItemId): ?int
|
||||||
|
{
|
||||||
|
if ($relatedItemId === null || $relatedItemId === '' || (int)$relatedItemId === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRelatedItemId = (int)$relatedItemId;
|
||||||
|
if ($normalizedRelatedItemId < 1) {
|
||||||
|
throw new RuntimeException('Related item ID must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT order_id
|
||||||
|
FROM order_items
|
||||||
|
WHERE id = {$normalizedRelatedItemId}
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
if (!$result || $result->num_rows < 1) {
|
||||||
|
throw new RuntimeException('Related order item not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
if ((int)($row['order_id'] ?? 0) !== $orderId) {
|
||||||
|
throw new RuntimeException('Related order item must belong to the same order');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalizedRelatedItemId;
|
||||||
|
}
|
||||||
|
|
||||||
public function add(int $order_id, int $product_id, string $reference, string $notes, int $cashier_id, int $price, int $quantity, $related_item_id = null): void
|
public function add(int $order_id, int $product_id, string $reference, string $notes, int $cashier_id, int $price, int $quantity, $related_item_id = null): void
|
||||||
{
|
{
|
||||||
global $db, $response;
|
global $db, $response;
|
||||||
try {
|
try {
|
||||||
|
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
||||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Avoid SQL injection
|
// Avoid SQL injection
|
||||||
$reference = $db->escape_string($reference);
|
$reference = $db->escape_string($reference);
|
||||||
@@ -169,6 +202,7 @@ class order_items_o extends db
|
|||||||
try {
|
try {
|
||||||
// Get the order
|
// Get the order
|
||||||
$order = (new orders_o())->getOrderById($order_id);
|
$order = (new orders_o())->getOrderById($order_id);
|
||||||
|
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
||||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Get the product price
|
// Get the product price
|
||||||
$product = (new products_o())->getProductById($product_id);
|
$product = (new products_o())->getProductById($product_id);
|
||||||
@@ -178,7 +212,7 @@ class order_items_o extends db
|
|||||||
// Check if the user has a discount on the product, or category
|
// Check if the user has a discount on the product, or category
|
||||||
$customer = (new orders_o())->getOrderCustomer($order_id);
|
$customer = (new orders_o())->getOrderCustomer($order_id);
|
||||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||||
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
|
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false, (int)$order->department_id->value());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the price is forced, set the price to the forced price
|
// If the price is forced, set the price to the forced price
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
|
require_once WD . '/classes/department_wash_count_service.php';
|
||||||
|
|
||||||
use attachments\helpers\attachment_content;
|
use attachments\helpers\attachment_content;
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\department_wash_count_service;
|
||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\invoicing_period_utils;
|
use classes\invoicing_period_utils;
|
||||||
use classes\orders_schema_bootstrap;
|
use classes\orders_schema_bootstrap;
|
||||||
@@ -1256,6 +1259,7 @@ class orders_o extends db
|
|||||||
o.reg_1,
|
o.reg_1,
|
||||||
o.reg_2,
|
o.reg_2,
|
||||||
o.reg_3,
|
o.reg_3,
|
||||||
|
o.completed_at,
|
||||||
o.invoice_collection_id,
|
o.invoice_collection_id,
|
||||||
CASE
|
CASE
|
||||||
WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice
|
WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice
|
||||||
@@ -1296,6 +1300,7 @@ class orders_o extends db
|
|||||||
o.reg_1,
|
o.reg_1,
|
||||||
o.reg_2,
|
o.reg_2,
|
||||||
o.reg_3,
|
o.reg_3,
|
||||||
|
o.completed_at,
|
||||||
o.invoice_collection_id,
|
o.invoice_collection_id,
|
||||||
o.include_in_invoice,
|
o.include_in_invoice,
|
||||||
department_flags.exclude_from_invoicing
|
department_flags.exclude_from_invoicing
|
||||||
@@ -1326,6 +1331,7 @@ class orders_o extends db
|
|||||||
'reg_1' => (string)($row['reg_1'] ?? ''),
|
'reg_1' => (string)($row['reg_1'] ?? ''),
|
||||||
'reg_2' => (string)($row['reg_2'] ?? ''),
|
'reg_2' => (string)($row['reg_2'] ?? ''),
|
||||||
'reg_3' => (string)($row['reg_3'] ?? ''),
|
'reg_3' => (string)($row['reg_3'] ?? ''),
|
||||||
|
'completed_at' => !empty($row['completed_at']) ? (string)$row['completed_at'] : null,
|
||||||
'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1,
|
'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1,
|
||||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||||
'queue_status' => null,
|
'queue_status' => null,
|
||||||
@@ -1436,7 +1442,7 @@ class orders_o extends db
|
|||||||
throw new Exception('No user found matching the customer number in the usage log');
|
throw new Exception('No user found matching the customer number in the usage log');
|
||||||
}
|
}
|
||||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||||
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price);
|
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value());
|
||||||
}
|
}
|
||||||
$order_item->notes->set(null); // Set notes for the simulated order item
|
$order_item->notes->set(null); // Set notes for the simulated order item
|
||||||
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
||||||
@@ -1509,7 +1515,7 @@ class orders_o extends db
|
|||||||
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||||
return $price;
|
return $price;
|
||||||
}
|
}
|
||||||
return $current_user->applyProductCustomerPricing((int)$product->id, $price);
|
return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1598,7 +1604,7 @@ class orders_o extends db
|
|||||||
}
|
}
|
||||||
$unitPrice = (int)$department_price_cache[$product_id]['price'];
|
$unitPrice = (int)$department_price_cache[$product_id]['price'];
|
||||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
|
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
|
||||||
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false);
|
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id);
|
||||||
}
|
}
|
||||||
$post_discount = $unitPrice * $quantity;
|
$post_discount = $unitPrice * $quantity;
|
||||||
$total += $post_discount;
|
$total += $post_discount;
|
||||||
@@ -2005,33 +2011,7 @@ class orders_o extends db
|
|||||||
|
|
||||||
public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int
|
public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int
|
||||||
{
|
{
|
||||||
global /** @var db $db */
|
return (new department_wash_count_service())->countInDateRange($date_start, $date_end, $department_id);
|
||||||
$db;
|
|
||||||
// Validate the date range
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
// Prepare the SQL query to count washes in the date range for the department
|
|
||||||
$date_start = $db->escape_string($date_start);
|
|
||||||
$date_end = $db->escape_string($date_end);
|
|
||||||
// Get the amount of orders with at least one order item that has a product with the is_wash column set to true
|
|
||||||
$sql = "SELECT COUNT(DISTINCT o.id) AS wash_count
|
|
||||||
FROM $this->table o
|
|
||||||
JOIN order_items oi ON oi.order_id = o.id
|
|
||||||
JOIN products p ON p.id = oi.product_id
|
|
||||||
WHERE o.department_id = $department_id
|
|
||||||
AND o.created_at BETWEEN '$date_start' AND '$date_end'
|
|
||||||
AND o.deleted_at IS NULL
|
|
||||||
AND p.is_wash = 1";
|
|
||||||
$result = $db->query($sql);
|
|
||||||
if ($result->num_rows === 0) {
|
|
||||||
return 0; // No washes found in the date range
|
|
||||||
}
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['wash_count'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2041,58 +2021,7 @@ class orders_o extends db
|
|||||||
*/
|
*/
|
||||||
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
|
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
|
||||||
{
|
{
|
||||||
global /** @var db $db */
|
return (new department_wash_count_service())->countByHourForDepartments($date_start, $date_end, $department_ids);
|
||||||
$db;
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized_department_ids = [];
|
|
||||||
foreach ($department_ids as $department_id) {
|
|
||||||
$normalized_id = (int)$department_id;
|
|
||||||
if ($normalized_id > 0) {
|
|
||||||
$normalized_department_ids[$normalized_id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($normalized_department_ids === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids)));
|
|
||||||
$escaped_start = $db->escape_string($date_start);
|
|
||||||
$escaped_end = $db->escape_string($date_end);
|
|
||||||
|
|
||||||
$sql = "SELECT o.department_id,
|
|
||||||
DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
|
|
||||||
COUNT(DISTINCT o.id) AS wash_count
|
|
||||||
FROM $this->table 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 p.is_wash = 1
|
|
||||||
GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')";
|
|
||||||
|
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -326,13 +326,13 @@ class products_o extends db
|
|||||||
return $this->getDepartmentPriceResolution($department_id)['price'];
|
return $this->getDepartmentPriceResolution($department_id)['price'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
public function applyCustomerDiscounts(array $products, users_o $customer, ?int $departmentId = null): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
// Get the customer's discounts
|
// Get the customer's discounts
|
||||||
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer), $products);
|
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer, $departmentId), $products);
|
||||||
}
|
}
|
||||||
public function applyCustomerDiscount(array $product, users_o $customer): array
|
public function applyCustomerDiscount(array $product, users_o $customer, ?int $departmentId = null): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
// Validate input
|
// Validate input
|
||||||
@@ -340,7 +340,7 @@ class products_o extends db
|
|||||||
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
|
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
|
||||||
}
|
}
|
||||||
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
|
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
|
||||||
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
|
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price'], true, $departmentId);
|
||||||
}
|
}
|
||||||
unset($product[self::PRICE_SOURCE_KEY]);
|
unset($product[self::PRICE_SOURCE_KEY]);
|
||||||
return $product;
|
return $product;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user