Files
api/services/nginx/app/routes/xlvaskUsageLogsRoute.php
T
Jeppe Bandopenhands 5441fea665 fix(api): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#367)
## Summary

Removes the XLVask autopilot / automation / MiniMax / OpenAI pipeline
and the related module config, CLI, cron, and migration scaffolding. The
Selvvask view (Superuser -> Fakturaer -> Periode -> Selvvask) is reduced
to a single read-only listing of usage logs plus operator-driven ignore
/ unignore / accept / reject endpoints gated on the
`review_xlvask_usage_order` permission.

See `inventory/self-serve-inventory.md` for the full surface map.

## Test plan

- [x] `vendor/bin/pest --testsuite=Unit` -> **1266 passed**, 1 unrelated
pre-existing failure (`BirdControlPlaneActivationTest`, needs
`PLENO_REPO_ROOT_FOR_TESTS`).
- [x] `php -l` on every modified PHP file -> no syntax errors.
- [x] Grep validation -> zero production-code references to removed
surfaces (`xlvask_autopilot_service`, `xlvask_automation_service`,
`xlvask_automation_policy_service`, `EnsureXLVaskAutomationSchema`,
`runScheduledAutomationIfReady`, `processAutopilotQueue`, `MiniMax`,
`minimax`, ...).
- [ ] Qodana + Tests workflows green on this PR.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:02:30 +02:00

427 lines
19 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\xlvask;
use objects\orders_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 () {
$permission_list_own = 'list_xlvask_usage_orders_own';
$permission_list_all = 'list_xlvask_usage_orders_all';
$response_includes_items = false;
global $response;
if (!$this->hasPermission($permission_list_all)) {
$this->requirePermission($permission_list_own);
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
if ($allowedHallIds === []) {
$response->error('No XL Vask hall scope is available', 403);
}
$xlvask_usage_logs = new xlvask_usage_logs_o();
$xlvask = new xlvask();
$linked_order_ids_by_wash_id = [];
$xlvask_usage_log_class = $xlvask->helpers->xlvask_usage_log;
if (ini_get('memory_limit') < '5120M') {
ini_set('memory_limit', '5120M');
}
$result = $xlvask_usage_logs
->setAdditionalWhereClause(
"`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log_class::$default_customers) . "')"
)
->listObjectsWithPaginationIfSet(
function ($log) use (
$response_includes_items,
$xlvask_usage_logs,
$xlvask,
&$linked_order_ids_by_wash_id
) {
$id = (int)$log['id'];
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
unset($log['id']);
$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',
]));
$tmp = $xlvask->new($xlvask_usage_log_class);
$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;
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
$tmp_res = $isEligibleForAutomaticContinuance
? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items)
: [];
$tmp_res['order']['customer_name'] = $tmp->Customer;
$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'];
unset($tmp);
return [
'id' => $id,
'fast_link_key' => null,
'usage_log_id' => $id,
'linked_order_id' => $linked_order_id,
'ignored_at' => $log['ignored_at'] ?? null,
'ignored_by' => isset($log['ignored_by']) ? (int)$log['ignored_by'] : null,
'ignored_reason' => $log['ignored_reason'] ?? null,
...$tmp_res['order'],
];
},
$xlvask_usage_logs->forceRestrictFilters([
'HallId' => $allowedHallIds,
'FinishStatus' => ['1'],
])
);
$response->success($result);
}, [
'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;
$allowedHallIds = $this->allowedHallIdsForUser($user);
$summary = (new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(
$dateFrom,
$dateTo,
$allowedHallIds
);
$response->success(['summary' => $summary]);
}, [
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log summary',
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log summaries',
]);
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$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::requireParameters(['reason']);
$reason = trim((string)$this->getParameter('reason'));
if (mb_strlen($reason) > 500) {
$response->error('Reason is too long (max 500 characters)', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set($reason);
$log->objectChanged();
$response->success([
'id' => $id,
'ignored_at' => $log->ignored_at->get(),
'ignored_by' => (int)$log->ignored_by->get(),
'ignored_reason' => $log->ignored_reason->get(),
]);
}, [
'review_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
]);
$this->post('/modules/xlvask/services/usage/orders/{id}/unignore', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$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);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(null);
$log->ignored_by->set(null);
$log->ignored_reason->set(null);
$log->objectChanged();
$response->success(['id' => $id]);
}, [
'review_xlvask_usage_order' => 'Clear ignore metadata on an XL Vask usage log',
]);
$this->post('/modules/xlvask/services/usage/orders/{id}/accept', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$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);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$xlvask = new xlvask();
$log_helper = new ($xlvask->helpers->xlvask_usage_log)();
$log_helper->setProperties(array_intersect_key($log->toArray(), array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
])));
$customer = $log_helper->getCustomer();
if ($customer === null) {
$response->error('XL Vask customer is not linkable', 422);
}
if (empty($customer->externId)) {
$response->error('XL Vask customer has no external id', 422);
}
$tmp_user = $customer->getUser();
if (!$tmp_user) {
$response->error('XL Vask customer is not provisioned in this system', 422);
}
try {
$order = $xlvask->getTasks()->createOrderFromWash($log_helper, $customer);
} catch (\Throwable $e) {
error_log('[xlvask-accept] createOrderFromWash failed: ' . $e->getMessage());
$response->error('Could not create order from XL Vask usage log: ' . $e->getMessage(), 422);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set('Accepted and converted to order ' . (int)$order->id);
$log->objectChanged();
$response->success([
'order_id' => (int)$order->id,
'usage_log_id' => $id,
]);
}, [
'review_xlvask_usage_order' => 'Convert an XL Vask usage log into an order',
]);
$this->post('/modules/xlvask/services/usage/orders/{id}/reject', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$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::requireParameters(['reason']);
$reason = trim((string)$this->getParameter('reason'));
if (mb_strlen($reason) > 500) {
$response->error('Reason is too long (max 500 characters)', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set('Rejected: ' . $reason);
$log->objectChanged();
$response->success([
'id' => $id,
'ignored_at' => $log->ignored_at->get(),
'ignored_by' => (int)$log->ignored_by->get(),
'ignored_reason' => $log->ignored_reason->get(),
]);
}, [
'review_xlvask_usage_order' => 'Reject an XL Vask usage log with a reviewer note',
]);
$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']);
$fast_link_key = (string)self::getParameter('fast_link_key');
self::requireType($fast_link_key, self::type_string());
self::requireMinLength('fast_link_key', 20);
self::requireMaxLength('fast_link_key', 50);
if (!preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
$response->error('Invalid fast link key format', 400);
}
$cached_data = redis->get($fast_link_key);
if (!$cached_data) {
$response->error('Fast link key not found', 404);
}
$data = json_decode($cached_data, true);
if (!is_array($data)) {
$response->error('Invalid cached data', 400);
}
$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);
}
redis->delete($fast_link_key);
$xlvask = new xlvask();
$order_arr = (new orders_o())->simulateOrderFromXLVask(
$xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data),
true
);
$tmp_order_obj = (object)[];
$tmp_order_arr = $order_arr['order'] ?? [];
$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,
),
]);
}, [
'fast_link_key' => 'string',
]);
}
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);
}
}
}