Files
api/services/nginx/app/routes/xlvaskUsageLogsRoute.php
T
Jeppe BandCleanup Agent 3e89085296 feat(api): support manual XL Vask operator decisions via force_manual (#356)
Adds a deterministic manual-suggestion path so operators can drive
accept/reject/ignore decisions on the self-wash view before the AI
autopilot has produced a suggestion. Whitelists force_manual in the
preview route. Adds unit tests for the new constant, method, and route
contract.

---------

Co-authored-by: Cleanup Agent <agent@truckwash.io>
2026-08-09 21:48:03 +02:00

689 lines
34 KiB
PHP

<?php
namespace routes;
require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/classes/xlvask_automation_policy_service.php';
use classes\authentication;
use classes\redis;
use classes\response;
use classes\stripe;
use classes\xlvask;
use classes\xlvask_autopilot_service;
use classes\xlvask_automation_service;
use classes\xlvask_automation_policy_service;
use objects\collected_order_invoices_o;
use objects\departments_o;
use objects\economic_module_orders;
use objects\orders_o;
use objects\stripe_module_orders_o;
use objects\stripe_payment_intents_o;
use objects\xlvask_usage_logs_o;
use traits\route_t;
class xlvaskUsageLogsRoute
{
use route_t;
public function run(): void
{
$this->get('/modules/xlvask/services/usage/orders', function () {
// Define the permissions:
$permission_list_own = 'list_xlvask_usage_orders_own'; // Permission to list own orders (Without department filter)
$permission_list_all = 'list_xlvask_usage_orders_all'; // Permission to list all orders (With department filter)
$response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly)
// Require the user to be logged in
global $response;
if (!$this->hasPermission($permission_list_all)) {
$this->requirePermission($permission_list_own);
}
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$allowedHallIds = $this->allowedHallIdsForUser($user);
if ($allowedHallIds === []) {
$response->error('No XL Vask hall scope is available', 403);
return;
}
$xlvask_usage_logs = new xlvask_usage_logs_o();
$xlvask = new xlvask();
$automation_service = new xlvask_automation_service();
$linked_order_ids_by_wash_id = [];
$xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment();
$orders_o = new orders_o();
$xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log);
// Increase the memory limit to 512MB (Provided it's currently less than that)
if (ini_get('memory_limit') < '5120M') {
ini_set('memory_limit', '5120M');
}
// Return the list of usage logs
$result = $xlvask_usage_logs
// Make sure the Customer is not in the default customers list
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
->listObjectsWithPaginationIfSet(
function ($log) use ($response_includes_items, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
// Remove the 'id' field from the log
$id = (int)$log['id'];
$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
unset($log['id']);
// Convert the 'WashItems' field from JSON to an array
$log['WashItems'] = json_decode($log['WashItems'], true);
$usage_log_payload = array_intersect_key($log, array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
'ignored_at',
'ignored_by',
'ignored_reason',
]));
// Create a new xlvask usage log object
$tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log);
// Set the properties of the temporary object
$tmp->setProperties($usage_log_payload);
$wash_id = (string)$tmp->WashId;
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
$linked_order = (new orders_o())->selectByWashId($wash_id);
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null;
}
$linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null;
// Define the result structure
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
// Return the result
$tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []);
$tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
// Clear memory
unset($tmp);
// Return the result
return [
'id' => $id, // Return the ID of the log
'fast_link_key' => null,
'automation' => $automation,
'source_hash' => $log['source_hash'] ?? null,
'source_revision' => $log['source_revision'] ?? null,
'source_observed_at' => $log['source_observed_at'] ?? null,
'source_stable_since' => $log['source_stable_since'] ?? null,
'source_observation_count' => (int)($log['source_observation_count'] ?? 0),
'import_state' => $log['import_state'] ?? 'unchanged',
'resolution_state' => $log['resolution_state'] ?? 'needs_review',
'certainty' => $log['certainty'] ?? 'none',
'planned_action' => $log['planned_action'] ?? 'none',
'state_reason' => $log['state_reason'] ?? null,
'expected_version' => isset($log['expected_version']) ? (int)$log['expected_version'] : 1,
'last_run_id' => isset($log['last_run_id']) ? (int)$log['last_run_id'] : null,
'last_evaluated_at' => $log['last_evaluated_at'] ?? null,
...$tmp_res['order'], // Return the simulated order from XLVask (with or without items)
'usage_log_id' => $id,
'linked_order_id' => $linked_order_id,
];
},
$xlvask_usage_logs->forceRestrictFilters(
[
// This makes sure that the user can only see department logs that belong to their departments
'HallId' => $allowedHallIds,
'FinishStatus' => ['1'], // Only show finished logs
]
)
);
// Return the response
$response->success($result);
} else {
// Return an error
$response->error('Invalid session', 400);
}
},
[
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
]
);
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
global $response;
if (!$this->hasPermission('list_xlvask_usage_orders_all')) {
$this->requirePermission('list_xlvask_usage_orders_own');
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
$response->success([
'summary' => (new xlvask_autopilot_service())->getSummary(
$dateFrom,
$dateTo,
$this->allowedHallIdsForUser($user)
),
]);
},
[
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary',
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries',
]
);
$this->post('/modules/xlvask/services/usage/autopilot-runs', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach ([
'ids',
'dateFrom',
'dateTo',
'limit',
'forceRefetch',
'mode',
'idempotency_key',
'aiTimeline',
'aiBatchSize',
'aiMaxCostUsd',
'aiInputUsdPer1mUsd',
'aiOutputUsdPer1mUsd',
] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success([
'run' => (new xlvask_autopilot_service())->createRun(
$input,
(int)$user->id,
$this->allowedHallIdsForUser($user)
),
], 202);
},
[
'manage_xlvask_usage_automation' => 'Create an XL Vask usage-log autopilot run',
]
);
$this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
$response->success((new xlvask_automation_policy_service())->readinessReadOnly(
$dateFrom,
$dateTo,
$this->allowedHallIdsForUser($user)
));
}, [
'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness',
]);
$this->get('/modules/xlvask/services/usage/automation/capabilities', function () {
global $response;
if (!$this->hasPermission('list_xlvask_usage_orders_all')
&& !$this->hasPermission('list_xlvask_usage_orders_own')) {
$this->requirePermission('list_xlvask_usage_orders_own');
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
$service = new xlvask_automation_policy_service();
$capabilities = $service->capabilitiesReadOnly($dateFrom, $dateTo, $this->allowedHallIdsForUser($user));
$canManage = $this->hasPermission('manage_xlvask_usage_automation');
$canManagePolicy = $this->hasPermission('superuser_xlvask_automation_activate');
$response->success([
'can_view' => true,
'can_review' => $canManage,
'can_dry_run' => $canManage,
'can_execute' => $canManage && in_array('execute', $capabilities['allowed_modes'], true),
'can_manage_policy' => $canManagePolicy,
'can_halt' => $canManagePolicy,
...$capabilities,
]);
}, [
'list_xlvask_usage_orders_own' => 'Inspect XL Vask automation capabilities',
]);
$this->get('/modules/xlvask/services/usage/autopilot-runs/active', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success(['run' => (new xlvask_automation_policy_service())->activeRunReadOnly(
$this->allowedHallIdsForUser($user)
)]);
}, ['manage_xlvask_usage_automation' => 'Inspect the active XL Vask automation run']);
$this->post('/modules/xlvask/services/usage/automation/admin/policy/previews', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['target_stage', 'reason']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success(['preview' => (new xlvask_automation_policy_service())->createPolicyPreview(
(string)$this->getParameter('target_stage'),
(string)$this->getParameter('reason'),
(int)$user->id
)]);
}, ['superuser_xlvask_automation_activate' => 'Preview an XL Vask automation stage transition']);
$this->post('/modules/xlvask/services/usage/automation/admin/policy/apply', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['preview_id', 'selection_hash', 'confirmation_text']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success((new xlvask_automation_policy_service())->applyPolicyPreview([
'preview_id' => $this->getParameter('preview_id'),
'selection_hash' => $this->getParameter('selection_hash'),
'confirmation_text' => $this->getParameter('confirmation_text'),
], (int)$user->id));
}, ['superuser_xlvask_automation_activate' => 'Apply a previewed XL Vask automation stage transition']);
$this->post('/modules/xlvask/services/usage/automation/admin/halt', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$reason = $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : '';
$response->success((new xlvask_automation_policy_service())->halt((int)$user->id, $reason));
}, ['superuser_xlvask_automation_activate' => 'Immediately halt XL Vask automatic actions']);
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/backtest', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['segment_key']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success([
'artifact' => (new xlvask_autopilot_service())->generateCalibrationArtifact(
trim((string)$this->getParameter('segment_key')),
(int)$user->id
),
]);
}, [
'superuser_xlvask_automation_activate' => 'Generate an inactive XL Vask historical calibration artifact',
]);
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/labels', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['suggestion_id', 'outcome']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
$result = (new xlvask_autopilot_service())->adjudicateCalibrationLabel(
(int)$this->getParameter('suggestion_id'),
trim((string)$this->getParameter('outcome')),
(int)$user->id,
$allowedHallIds
);
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
$response->success([
...$result,
'readiness' => (new xlvask_automation_policy_service())->readinessReadOnly($dateFrom, $dateTo, $allowedHallIds),
]);
}, [
'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence',
]);
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['artifact_hash', 'confirmation_text']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success((new xlvask_autopilot_service())->activateCalibration(
(int)($this->fromRoute('id') ?? 0),
trim((string)$this->getParameter('artifact_hash')),
(string)$this->getParameter('confirmation_text'),
(int)$user->id
));
}, [
'superuser_xlvask_automation_activate' => 'Explicitly activate a qualifying XL Vask calibration artifact',
]);
$this->post('/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['confirmation_text']);
$response->success((new xlvask_autopilot_service())->activateWashIdUniqueness(
(string)$this->getParameter('confirmation_text')
));
}, [
'superuser_xlvask_automation_activate' => 'Explicitly activate the guarded XL Vask wash-id uniqueness migration',
]);
$this->get('/modules/xlvask/services/usage/autopilot-runs/{id}', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask autopilot run id', 400);
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$run = (new xlvask_autopilot_service())->getRun(
$id,
(int)$user->id,
$this->allowedHallIdsForUser($user)
);
$response->success(['run' => $run]);
},
[
'manage_xlvask_usage_automation' => 'Read XL Vask usage-log autopilot run status',
]
);
$this->post('/modules/xlvask/services/usage/automation/decisions/preview', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success([
'preview' => (new xlvask_autopilot_service())->createDecisionPreview(
$input,
(int)$user->id,
$this->allowedHallIdsForUser($user)
),
]);
}, ['manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision']);
$this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach (['preview_id', 'selection_hash', 'confirmation_text'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success(
(new xlvask_autopilot_service())->applyDecision(
$input,
(int)$user->id,
$this->allowedHallIdsForUser($user)
)
);
}, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']);
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
global $response;
$this->requirePermission('ignore_xlvask_usage_order');
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
},
[
'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
]
);
$this->post('/modules/xlvask/services/usage/orders/automation/run', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
},
[
'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
},
[
'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion',
]
);
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
global $response;
$this->requirePermission('list_xlvask_usage_orders_own');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
self::requireParameters([
'fast_link_key', // Example: 'temporary_cache_6878cf0603d77'
]);
// Get the fast link key from the request
$fast_link_key = (string)self::getParameter('fast_link_key');
self::requireType($fast_link_key, self::type_string());
self::requireMinLength('fast_link_key', 20); // Minimum length of the fast link key
self::requireMaxLength('fast_link_key', 50); // Maximum length of the fast link key
// Check if the fast link key is valid
// First, check if the key has the correct format
if (preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
// Get the cached data from Redis
$cached_data = redis->get($fast_link_key);
// Check if the cached data is valid
if ($cached_data) {
// Decode the cached data
$data = json_decode($cached_data, true);
// Check if the data is valid
if (is_array($data)) {
$allowedHallIds = $this->allowedHallIdsForUser($user);
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
$response->error('Fast link is outside the current XL Vask hall scope', 403);
}
// Delete the cached data from Redis
redis->delete($fast_link_key);
// Return the data
$xlvask = new xlvask();
$order_arr = (new orders_o())->simulateOrderFromXLVask($xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data), true); // ['order' => $order_arr, 'order_items' => $items_arr]
$tmp_order_obj = (object)[];
$tmp_order_arr = $order_arr['order'] ?? [];
/**
* "id": -1,
* "customer_id": 39159000,
* "cashier_id": 2285,
* "reference": "Simulated Order from XL Vask",
* "notes": "This is a simulated order generated from an XL Vask usage log",
* "department_id": 1,
* "reg_1": "DE55248",
* "reg_2": "",
* "reg_3": "",
* "completed_at": null,
* "created_at": "2025-07-16 13:04:54",
* "deleted_at": null,
* "total_net_amount": 683,
* "invoice_collection_id": 0,
* "booking_id": 0,
* "wash_id": "b24728ea-e22b-4dce-8cd3-a0998f7fdc5e",
* "lane": 1,
* "closed_at": null
*/
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
$response->success([
...$order_arr,
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
$tmp_order_obj->reg_1,
$tmp_order_obj->reg_2,
$tmp_order_obj->reg_3,
$tmp_order_obj->department_id,
$tmp_order_obj->created_at,
),
]);
} else {
// Return an error if the data is not valid
$response->error('Invalid cached data', 400);
}
} else {
// Return an error if the fast link key does not exist in Redis
$response->error('Fast link key not found', 404);
}
} else {
// Return an error if the fast link key is invalid
$response->error('Invalid fast link key format', 400);
}
},
[
'fast_link_key' => 'string', // Example: 'temporary_cache_6878cf0603d77'
]
);
}
private function allowedHallIdsForUser(object $user): array
{
global $db;
if ($this->hasPermission('list_xlvask_usage_orders_all')) {
$sqlWithSoftDelete = "SELECT DISTINCT HallId FROM plate_scanners
WHERE HallId IS NOT NULL AND TRIM(HallId) <> '' AND deleted_at IS NULL";
$sqlWithoutSoftDelete = "SELECT DISTINCT HallId FROM plate_scanners
WHERE HallId IS NOT NULL AND TRIM(HallId) <> ''";
try {
$result = $db->query($sqlWithSoftDelete);
} catch (\Throwable $throwable) {
$message = (string)$throwable->getMessage();
$missingDeletedAt = str_contains($message, "Unknown column 'deleted_at'")
|| str_contains($message, "Unknown column `deleted_at`");
if (!$missingDeletedAt) {
throw $throwable;
}
$result = $db->query($sqlWithoutSoftDelete);
}
$hallIds = $result === false ? [] : array_map(
static fn(array $row): string => trim((string)($row['HallId'] ?? '')),
$db->fetch_all($result)
);
} else {
$hallIds = (array)$user->getGroup()->getDepartmentsScannersHallIds();
}
return array_values(array_unique(array_filter(
array_map(static fn(mixed $id): string => trim((string)$id), $hallIds),
static fn(string $id): bool => $id !== '' && strlen($id) <= 191
)));
}
private static function requireUsageLogInHallScope(int $usageLogId, array $allowedHallIds): void
{
global $db, $response;
if ($allowedHallIds === []) {
$response->error('XL Vask usage log not found', 404);
}
$hallSql = implode(',', array_map(
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
$allowedHallIds
));
$result = $db->query(
"SELECT id FROM xlvask_usage_logs WHERE id = {$usageLogId} AND HallId IN ({$hallSql}) LIMIT 1"
);
if ($result === false || $result->num_rows < 1) {
$response->error('XL Vask usage log not found', 404);
}
}
}