Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
Jeppe Bundgaard 0cca597fdc Fix XLVask usage import dates
Fix XLVask usage-log import metadata and period-scoped Selvvask automation.
2026-07-06 23:49:28 +02:00
Jeppe B 709c6acbba Fix product null department permissions
Treats null-like optional product query params as omitted and avoids department_access_0 permission checks.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard ed2736e528 Fix product null department permissions 2026-07-06 19:56:07 +02:00
Jeppe B c7f5c73a9e Merge pull request #303 from copenhagentruckwash/codex/daily-report-product-targets-api
[codex] Add daily report product target API
2026-07-06 19:35:37 +02:00
11 changed files with 976 additions and 91 deletions
@@ -14,14 +14,18 @@ class limited_backoffice_service
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
private const MANAGED_EMPLOYEE_CUSTOMER_NUMBER = 0;
/**
* Permissions required for managed employees to sign in and appear in the employee login picker.
* Permissions required for managed employees to sign in, appear in the employee login picker,
* and open the department admin shell used by their scoped role permissions.
*
* @var array<int, string>
*/
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
'admin',
'user',
'permissions_list_own',
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
];
@@ -30,8 +34,8 @@ class limited_backoffice_service
*/
private const ROLE_PRESETS = [
'viewer' => [
'label' => 'Viewer',
'description' => 'Can sign in and view assigned department data.',
'label' => 'Deactivated',
'description' => 'Keeps the employee registered without order, booking, or management permissions.',
'permissions' => [
'user',
'permissions_list_own',
@@ -39,18 +43,52 @@ class limited_backoffice_service
],
'cashier' => [
'label' => 'Cashier',
'description' => 'Can work with orders and order lines for assigned departments.',
'description' => 'Can work with POS orders, products, customers, vehicles, attachments, payments, scanners, and bookings for assigned departments.',
'permissions' => [
'user',
'permissions_list_own',
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
],
],
'booking_coordinator' => [
@@ -64,6 +102,7 @@ class limited_backoffice_service
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'department_timebookings_entries_get',
@@ -78,18 +117,46 @@ class limited_backoffice_service
'user',
'permissions_list_own',
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'delete_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'statistics_orders_new',
@@ -103,18 +170,46 @@ class limited_backoffice_service
'user',
'permissions_list_own',
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'delete_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'statistics_orders_new',
@@ -142,6 +237,10 @@ class limited_backoffice_service
'group' => 'orders',
'capability' => 'view_orders',
],
'fetch_order' => [
'group' => 'orders',
'capability' => 'view_orders',
],
'add_order' => [
'group' => 'orders',
'capability' => 'create_orders',
@@ -154,6 +253,10 @@ class limited_backoffice_service
'group' => 'orders',
'capability' => 'delete_orders',
],
'mark_order_as_completed' => [
'group' => 'orders',
'capability' => 'complete_orders',
],
'list_order_items' => [
'group' => 'orders',
'capability' => 'view_order_items',
@@ -170,10 +273,110 @@ class limited_backoffice_service
'group' => 'orders',
'capability' => 'remove_order_lines',
],
'list_order_attachments' => [
'group' => 'attachments',
'capability' => 'view_order_attachments',
],
'add_order_attachments' => [
'group' => 'attachments',
'capability' => 'add_order_attachments',
],
'download_order_attachments' => [
'group' => 'attachments',
'capability' => 'download_order_attachments',
],
'list_products' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_order_recommended' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'vehicle_product_suggestions' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'search_customers' => [
'group' => 'customers',
'capability' => 'search_customers',
],
'get_user_from_customer_number' => [
'group' => 'customers',
'capability' => 'view_customer_details',
],
'list_customer_notes' => [
'group' => 'customers',
'capability' => 'view_customer_notes',
],
'add_customer_note' => [
'group' => 'customers',
'capability' => 'add_customer_notes',
],
'list_customer_attributes' => [
'group' => 'customers',
'capability' => 'view_customer_flags',
],
'search_vehicles' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'view_vehicle_status' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'list_unknown_customer_vehicles' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'list_vehicle_customer_suggestions' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'department_license_plate_lookup' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'department_vehicle_order_last_five' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'list_number_plate_scans' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'list_department_number_plate_scanners' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'charge_order' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'get_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'confirm_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_department_terminal_readers_list' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_invoice_send' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'list_bookings' => [
'group' => 'bookings',
'capability' => 'view_department_bookings',
@@ -190,6 +393,10 @@ class limited_backoffice_service
'group' => 'bookings',
'capability' => 'create_bookings',
],
'add_bookings' => [
'group' => 'bookings',
'capability' => 'create_bookings',
],
'complete_bookings' => [
'group' => 'bookings',
'capability' => 'mark_bookings_complete',
@@ -238,6 +445,11 @@ class limited_backoffice_service
private const ROLE_PERMISSION_GROUP_ORDER = [
'account',
'orders',
'products',
'customers',
'vehicles',
'attachments',
'scanner',
'bookings',
'time_bookings',
'reports',
@@ -333,6 +545,15 @@ class limited_backoffice_service
return [];
}
if ($user->hasPermission('superuser')) {
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
}
global $db;
$statement = $this->mysqli()->prepare(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?'
@@ -574,7 +795,7 @@ class limited_backoffice_service
try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$customerNumber = $this->generateEmployeeCustomerNumber();
$customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$statement = $mysqli->prepare(
@@ -599,15 +820,7 @@ class limited_backoffice_service
$employeeId = (int)$mysqli->insert_id;
$statement->close();
$groupName = 'Limited employee #' . $employeeId;
$groupDescription = 'Managed by limited backoffice.';
$statement = $mysqli->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
$this->renameManagedGroup($groupId, $employeeId);
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) {
@@ -641,6 +854,72 @@ class limited_backoffice_service
return $this->formatEmployee($employee, $departmentIds, true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function migrateEmployee(users_o $manager, int $employeeId, array $payload): array
{
$this->rejectRawPermissionPayload($payload);
$this->assertNotSelfEdit($manager, $employeeId);
if ($this->loadManagedEmployee($employeeId) !== null) {
throw new limited_backoffice_exception('User is already a limited backoffice employee.', 409);
}
$target = $this->loadMigratableUser($employeeId);
if ($target === null) {
throw new limited_backoffice_exception('User not found.', 404);
}
$this->assertMigrationTargetIsSafe($target);
$departmentIds = $this->normalizeDepartmentIds($payload['department_ids'] ?? null);
$this->assertDepartmentSubset($manager, $departmentIds);
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) {
throw new limited_backoffice_exception('Unable to encode department metadata.', 500);
}
$mysqli = $this->mysqli();
$mysqli->begin_transaction();
try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$this->renameManagedGroup($groupId, $employeeId);
$this->updateUserFields($employeeId, [
'group_id' => $groupId,
]);
$managerId = (int)$manager->id;
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`, `updated_by_user_id`)
VALUES (?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare migrated employee metadata insert.');
}
$statement->bind_param('iissii', $employeeId, $groupId, $roleKey, $departmentJson, $managerId, $managerId);
$statement->execute();
$statement->close();
$this->clearUserSessionCache($employeeId);
$mysqli->commit();
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to migrate employee.', 500);
}
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Unable to load migrated employee.', 500);
}
return $this->formatEmployee($employee, $departmentIds, true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
@@ -1295,6 +1574,19 @@ class limited_backoffice_service
return $groupId;
}
private function renameManagedGroup(int $groupId, int $employeeId): void
{
$groupName = 'Limited employee #' . $employeeId;
$groupDescription = 'Managed by limited backoffice.';
$statement = $this->mysqli()->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
}
/**
* @return array<int, string>
*/
@@ -1364,29 +1656,6 @@ class limited_backoffice_service
$insert->close();
}
private function generateEmployeeCustomerNumber(): int
{
$mysqli = $this->mysqli();
for ($attempt = 0; $attempt < 20; $attempt++) {
$customerNumber = random_int(900000000, 999999999);
$statement = $mysqli->prepare('SELECT `id` FROM `users` WHERE `customer_number` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare customer number check.');
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$exists = $result->num_rows > 0;
$statement->close();
if (!$exists) {
return $customerNumber;
}
}
throw new \RuntimeException('Unable to generate employee customer number.');
}
/**
* @return array<string, mixed>|null
*/
@@ -1424,6 +1693,42 @@ class limited_backoffice_service
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>|null
*/
private function loadMigratableUser(int $employeeId): ?array
{
global $db;
$userDeletedAtSelect = $this->tableHasColumn('users', 'deleted_at')
? 'u.`deleted_at` AS `user_deleted_at`'
: 'NULL AS `user_deleted_at`';
$statement = $this->mysqli()->prepare(
'SELECT
u.`id`,
u.`customer_number`,
u.`display_name`,
u.`email`,
u.`phone_country_code`,
u.`phone`,
u.`group_id`,
' . $userDeletedAtSelect . '
FROM `users` u
WHERE u.`id` = ?
LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to load user.', 500);
}
$statement->bind_param('i', $employeeId);
$statement->execute();
$result = $statement->get_result();
$row = $db->fetch_assoc($result);
$statement->close();
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $row
*/
@@ -1518,6 +1823,25 @@ class limited_backoffice_service
}
}
/**
* @param array<string, mixed> $target
*/
private function assertMigrationTargetIsSafe(array $target): void
{
if ((int)($target['customer_number'] ?? -1) !== self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER) {
throw new limited_backoffice_exception('Only employee accounts with customer number 0 can be migrated.', 400);
}
$groupId = (int)($target['group_id'] ?? 0);
if ($groupId === 1 || $this->groupHasPermission($groupId, 'superuser')) {
throw new limited_backoffice_exception('Cannot migrate superuser accounts.', 403);
}
if (($target['user_deleted_at'] ?? null) !== null) {
throw new limited_backoffice_exception('Cannot migrate inactive users.', 409);
}
}
private function groupHasPermission(int $groupId, string $permission): bool
{
if ($groupId <= 0) {
@@ -1246,19 +1246,20 @@ class xlvask_automation_service
{
global $db;
(new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = [
'FinishStatus = 1',
'(ignored_at IS NULL OR ignored_at = "")',
];
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
} else {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
}
if ($dateTo !== null && strtotime($dateTo) !== false) {
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
}
$limit = max(1, min(500, $limit));
@@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper
private function unsetNullifiableProperties(): void
{
$nullable_review_metadata = [
'ignored_at',
'ignored_reason',
];
// Unset properties that are null or empty strings
$properties = [
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
if ($this->isEmptyOrDefault($this->{$property})) {
$tmp_value = $this->{$property};
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) {
$this->{$property} = ''; // Set to null if it matches the default string
$this->{$property} = (
$tmp_value === $this->default_string_nullable
&& in_array($property, $nullable_review_metadata, true)
) ? null : '';
} elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
if ($tmp_value === $this->default_int_nullable) {
$this->{$property} = null; // Set to null if it matches the default int nullable
@@ -82,7 +82,7 @@ class xlvask_usage_logs_o extends db
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false);
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
}
@@ -195,18 +195,20 @@ class xlvask_usage_logs_o extends db
/**
* Import the usage logs from XL Vask
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
* @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'.
* @param string|null $dateTo Optional inclusive import end date.
* @throws Exception If the objects were not successfully added.
* @returns void
*/
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
{
if (!empty($this->id)) {
throw new Exception('To prevent issues, having a selected object is not allowed.');
}
$usage_logs = $this->getUsageLogsFromXLVask(
date('Y-m-d\TH:i:s.000', strtotime($dateTimeModifier)) // Example: '2025-05-01T00:00:00.000'
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
);
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
$known_usage_logIds = array_map(function ($log) {
return $log['WashId'];
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
unset($new_usage_logs);
}
private static function formatImportDateFrom(?string $dateFrom): string
{
$dateFrom = trim((string)($dateFrom ?? ''));
$timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom);
if ($timestamp === false) {
throw new Exception('Invalid XL Vask usage import dateFrom');
}
return date('Y-m-d\TH:i:s.000', $timestamp);
}
/**
* @param xlvask_usage_log[] $usageLogs
* @return xlvask_usage_log[]
* @throws Exception
*/
private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array
{
$dateTo = trim((string)($dateTo ?? ''));
if ($dateTo === '') {
return $usageLogs;
}
$dateToTimestamp = strtotime($dateTo);
if ($dateToTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
$inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp));
if ($inclusiveEndTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) {
$startTimestamp = strtotime((string)$log->StartTime);
return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp;
}));
}
/**
* This function retrieves the usage logs from XL Vask
* @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
@@ -78,6 +78,15 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/migrate', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission('superuser');
return $service->migrateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
});
}, [
'superuser' => 'Migrate existing employees to limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/import-usage', function () {
global $response;
self::requirePermission('modules_xlvask_import_usage');
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
// Create the xlvask_usage_logs_o object
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
// Import usage logs
$xlvask_usage_logs_o->importUsageLogs();
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
// Response
$response->success(
'Usage logs imported',
+75 -42
View File
@@ -22,21 +22,23 @@ class productsRoute
*/
private function getCustomerIfProvided(): ?users_o
{
global $response;
if (self::isParametersSet(['customer_id'])) {
$customerId = (int)self::getParameter('customer_id');
try {
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
$customerId = $this->getOptionalPositiveIntParameter('customer_id');
if ($customerId === null) {
return null;
}
try {
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
return null;
}
@@ -45,12 +47,49 @@ class productsRoute
* @return int|null
*/
private function getDepartmentIdIfProvided(): ?int
{
return $this->getOptionalPositiveIntParameter('department_id');
}
private function getOptionalPositiveIntParameter(string $parameter): ?int
{
global $response;
if (self::isParametersSet(['department_id'])) {
return (int)self::getParameter('department_id');
if (!self::isParametersSet([$parameter])) {
return null;
}
return null;
$value = self::getParameter($parameter);
if ($this->isNullLikeOptionalParameter($value)) {
return null;
}
$parsed = null;
if (is_int($value)) {
$parsed = $value;
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$parsed = (int)trim($value);
} else {
$response->error('Invalid ' . $parameter, 400);
}
if ($parsed === null || $parsed <= 0) {
$response->error('Invalid ' . $parameter, 400);
}
return $parsed;
}
private function isNullLikeOptionalParameter(mixed $value): bool
{
if ($value === null) {
return true;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
}
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
@@ -72,11 +111,7 @@ class productsRoute
*/
private function getCategoryIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['category'])) {
return (int)self::getParameter('category');
}
return null;
return $this->getOptionalPositiveIntParameter('category');
}
/**
@@ -85,11 +120,7 @@ class productsRoute
*/
private function getProductIdIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['id'])) {
return (int)self::getParameter('id');
}
return null;
return $this->getOptionalPositiveIntParameter('id');
}
/**
@@ -210,13 +241,14 @@ class productsRoute
// Check if the request was successful
if ($user || $isProductDetailsRestricted) {
// Define the variables
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$this->assertCanUseDepartmentPricing($user, $departmentId);
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$customer = $this->getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
// Check if the "final_price" parameter is set, and true.
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
if ($useFinalPrice) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Determine the products to return
if ($category) {
// Get products in the category
@@ -274,17 +306,17 @@ class productsRoute
);
}
// Check if the category is set in the request
$data = $_GET ?? [];
// Check if the category is set
if (isset($data['category'])) {
if ($category !== null) {
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
// Return the list of products
$products = (new products_o())->listObjectsByCategory($data['category']);
$products = (new products_o())->listObjectsByCategory($category);
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
}
$response->success(
array_map(function ($product) use ($isProductDetailsRestricted) {
@@ -295,9 +327,10 @@ class productsRoute
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Get all product ids contained in a category attached to the department
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
// Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) {
return $product->id;
@@ -312,7 +345,7 @@ class productsRoute
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
), (int)$data['department_id'])
), $departmentId)
);
}
// Return the list of products
@@ -26,18 +26,46 @@ function limited_backoffice_all_role_permissions(): array
'user',
'permissions_list_own',
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'delete_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'department_timebookings_entries_get',
@@ -48,6 +76,24 @@ function limited_backoffice_all_role_permissions(): array
];
}
function limited_backoffice_role_preset_permissions(string $roleKey): array
{
static $rolePresets = null;
if ($rolePresets === null) {
$reflection = new ReflectionClass(limited_backoffice_service::class);
$constant = $reflection->getReflectionConstant('ROLE_PRESETS');
if (!$constant instanceof ReflectionClassConstant) {
throw new RuntimeException('Limited backoffice role presets are unavailable.');
}
$rolePresets = $constant->getValue();
}
return $rolePresets[$roleKey]['permissions'] ?? [];
}
function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
@@ -575,6 +621,182 @@ it('rejects invalid price batches and leaves existing prices unchanged', functio
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
});
it('keeps higher limited backoffice roles as cashier permission supersets', function (): void {
$cashierPermissions = limited_backoffice_role_preset_permissions('cashier');
expect($cashierPermissions)->not->toBeEmpty();
foreach (['operations_lead', 'department_admin'] as $roleKey) {
$rolePermissions = limited_backoffice_role_preset_permissions($roleKey);
$missingPermissions = array_values(array_diff($cashierPermissions, $rolePermissions));
if ($missingPermissions !== []) {
throw new RuntimeException(
$roleKey . ' must include all cashier permissions; missing: ' . implode(', ', $missingPermissions)
);
}
}
});
it('lets superusers migrate existing employee accounts to limited backoffice employees', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Limited Migration Department']);
$legacyGroup = api_fixtures()->createGroup(['name' => 'Legacy Employee Group'], [
'employee_public_data',
]);
$legacyEmployee = api_fixtures()->createUser([
'customer_number' => 0,
'display_name' => 'Legacy Counter Employee',
'email' => 'legacy-counter@example.test',
'group_id' => $legacyGroup['id'],
]);
$superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]);
$departments = api_client()->get('/limited-backoffice/departments', $superuserSession['headers']);
$departments
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(array_map('intval', array_column($departments->data(), 'id')))->toContain((int)$department['id']);
$migrated = api_client()->post('/limited-backoffice/employees/' . (int)$legacyEmployee['id'] . '/migrate', [
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
], $superuserSession['headers']);
$migrated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
limited_backoffice_cleanup_created_employee((int)$legacyEmployee['id']);
expect($migrated->data()['id'] ?? null)->toBe((int)$legacyEmployee['id']);
expect($migrated->data()['customer_number'] ?? null)->toBe(0);
expect($migrated->data()['display_name'] ?? null)->toBe('Legacy Counter Employee');
expect($migrated->data()['email'] ?? null)->toBe('legacy-counter@example.test');
expect($migrated->data()['role']['key'] ?? null)->toBe('cashier');
expect(array_map('intval', array_column($migrated->data()['departments'] ?? [], 'id')))->toBe([(int)$department['id']]);
$metadata = api_test_runtime()->queryOne(
'SELECT `managed_group_id`, `role_key`, `department_ids`
FROM `limited_backoffice_employees`
WHERE `user_id` = ' . (int)$legacyEmployee['id'] . ' LIMIT 1'
);
expect($metadata)->not->toBeNull();
$managedGroupId = (int)($metadata['managed_group_id'] ?? 0);
expect($managedGroupId)->toBeGreaterThan(0);
expect($managedGroupId)->not->toBe((int)$legacyGroup['id']);
expect($metadata['role_key'] ?? null)->toBe('cashier');
expect(json_decode((string)($metadata['department_ids'] ?? '[]'), true))->toBe([(int)$department['id']]);
$userRow = api_test_runtime()->queryOne(
'SELECT `customer_number`, `group_id`, `display_name`, `email`
FROM `users`
WHERE `id` = ' . (int)$legacyEmployee['id'] . ' LIMIT 1'
);
expect((int)($userRow['customer_number'] ?? -1))->toBe(0);
expect((int)($userRow['group_id'] ?? 0))->toBe($managedGroupId);
expect($userRow['display_name'] ?? null)->toBe('Legacy Counter Employee');
expect($userRow['email'] ?? null)->toBe('legacy-counter@example.test');
$permissionRows = api_test_runtime()->db()->query(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $managedGroupId
)->fetch_all(MYSQLI_ASSOC);
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('admin')
->toContain('user')
->toContain('permissions_list_own')
->toContain('employee_public_data')
->toContain('department_access_' . (int)$department['id'])
->toContain('fetch_order')
->toContain('search_customers')
->toContain('add_order_attachments')
->toContain('list_number_plate_scans')
->toContain('add_bookings')
->not->toContain('superuser');
$listed = api_client()->get('/limited-backoffice/employees', $superuserSession['headers']);
$listedIds = array_map('intval', array_column($listed->data(), 'id'));
expect($listedIds)->toContain((int)$legacyEmployee['id']);
});
it('rejects unsafe limited backoffice employee migrations', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'auth');
api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'validation');
$department = api_fixtures()->createDepartment(['name' => 'Limited Migration Rejections']);
$limitedManagerSession = limited_backoffice_manager_session([(int)$department['id']]);
$superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]);
$legacyEmployee = api_fixtures()->createUser([
'customer_number' => 0,
'display_name' => 'Unsafe Migration Employee',
'email' => 'unsafe-migration-employee@example.test',
]);
api_client()->post('/limited-backoffice/employees/' . (int)$legacyEmployee['id'] . '/migrate', [
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $limitedManagerSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser']);
api_client()->post('/limited-backoffice/employees/' . (int)$superuserSession['user']['id'] . '/migrate', [
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $superuserSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managers cannot edit themselves.');
$customerUser = api_fixtures()->createUser(['customer_number' => 99112233]);
api_client()->post('/limited-backoffice/employees/' . (int)$customerUser['id'] . '/migrate', [
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $superuserSession['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Only employee accounts with customer number 0 can be migrated.');
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Already Managed Migration',
'email' => 'already-managed-migration@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $limitedManagerSession['headers']);
$alreadyManagedId = (int)($created->data()['id'] ?? 0);
expect($alreadyManagedId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($alreadyManagedId);
api_client()->post('/limited-backoffice/employees/' . $alreadyManagedId . '/migrate', [
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
], $superuserSession['headers'])
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('User is already a limited backoffice employee.');
$targetSuperuser = api_fixtures()->createUser([
'customer_number' => 0,
'email' => 'target-superuser-migration@example.test',
'group_id' => 1,
]);
api_client()->post('/limited-backoffice/employees/' . (int)$targetSuperuser['id'] . '/migrate', [
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $superuserSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot migrate superuser accounts.');
});
it('creates updates lists and deactivates scoped employees without exposing raw permissions', function (): void {
limited_backoffice_without_users_deleted_at(function (): void {
api_test_covers('GET /limited-backoffice/roles', 'happy');
@@ -598,12 +820,57 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
$rolesByKey = array_column($roles->data(), null, 'key');
expect($rolesByKey['viewer']['label'] ?? null)->toBe('Deactivated');
expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([
[
'key' => 'account',
'capabilities' => ['sign_in', 'view_own_permissions'],
],
]);
$cashierGroups = array_column($rolesByKey['cashier']['permission_groups'] ?? [], 'capabilities', 'key');
expect($cashierGroups['orders'] ?? null)->toBe([
'view_orders',
'create_orders',
'edit_orders',
'complete_orders',
'view_order_items',
'create_order_items',
'update_order_lines',
'remove_order_lines',
'charge_orders',
]);
expect($cashierGroups['products'] ?? null)->toBe([
'view_product_catalog',
'view_product_recommendations',
]);
expect($cashierGroups['customers'] ?? null)->toBe([
'search_customers',
'view_customer_details',
'view_customer_notes',
'add_customer_notes',
'view_customer_flags',
]);
expect($cashierGroups['vehicles'] ?? null)->toBe([
'search_vehicles',
'view_vehicle_matches',
'view_vehicle_history',
]);
expect($cashierGroups['attachments'] ?? null)->toBe([
'view_order_attachments',
'add_order_attachments',
'download_order_attachments',
]);
expect($cashierGroups['scanner'] ?? null)->toBe([
'view_plate_scans',
]);
expect($cashierGroups['bookings'] ?? null)->toBe([
'view_department_bookings',
'view_own_bookings',
'update_bookings',
'create_bookings',
'mark_bookings_complete',
'send_booking_confirmations',
]);
$departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key');
expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([
'open_limited_backoffice',
@@ -620,18 +887,46 @@ it('creates updates lists and deactivates scoped employees without exposing raw
});
foreach ([
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'delete_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'department_timebookings_entries_get',
@@ -665,6 +960,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
expect($created->data()['user_id'] ?? null)->toBe($employeeId);
expect($created->data()['customer_number'] ?? null)->toBe(0);
expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test');
expect($created->data()['phone_country_code'] ?? null)->toBe(45);
expect($created->data()['phone'] ?? null)->toBe(12345678);
@@ -680,9 +976,44 @@ it('creates updates lists and deactivates scoped employees without exposing raw
)->fetch_all(MYSQLI_ASSOC);
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('admin')
->toContain('user')
->toContain('permissions_list_own')
->toContain('department_access_' . (int)$department['id'])
->toContain('employee_public_data')
->toContain('add_order')
->toContain('fetch_order')
->toContain('list_products')
->toContain('search_customers')
->toContain('get_user_from_customer_number')
->toContain('search_vehicles')
->toContain('list_order_attachments')
->toContain('add_order_attachments')
->toContain('download_order_attachments')
->toContain('list_number_plate_scans')
->toContain('modules_stripe_department_terminal_readers_list')
->toContain('list_bookings')
->toContain('edit_bookings')
->toContain('add_bookings')
->not->toContain('superuser');
$employeeToken = api_fixtures()->createAuthToken($employeeId);
$employeeSession = api_client()->get('/auth/session', api_fixtures()->bearerHeaders($employeeToken));
$employeeSession
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($employeeSession->data()['permissions'] ?? [])
->toContain('admin')
->toContain('permissions_list_own')
->toContain('department_access_' . (int)$department['id'])
->toContain('add_order')
->toContain('list_products')
->toContain('search_customers')
->toContain('search_vehicles')
->toContain('add_order_attachments')
->toContain('list_bookings')
->toContain('edit_bookings')
->not->toContain('superuser');
$publicEmployees = api_client()->get('/public/employees');
@@ -732,8 +1063,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
->assertSuccess();
expect($deactivated->data()['active'] ?? true)->toBeFalse();
$userRow = api_test_runtime()->queryOne('SELECT `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId);
$userRow = api_test_runtime()->queryOne('SELECT `customer_number`, `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId);
expect($userRow)->not->toBeNull();
expect((int)($userRow['customer_number'] ?? -1))->toBe(0);
expect(array_key_exists('password', $userRow ?? []))->toBeTrue();
expect($userRow['password'])->toBeNull();
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
@@ -759,7 +1091,7 @@ it('caps limited employee permissions to the manager permissions and selected de
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
$rolesByKey = array_column($roles->data(), null, 'key');
$operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key');
expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in']);
expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in', 'view_own_permissions']);
expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']);
expect($roles->body)->not->toContain('create_orders');
expect($roles->body)->not->toContain('view_order_statistics');
@@ -790,11 +1122,17 @@ it('caps limited employee permissions to the manager permissions and selected de
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('admin')
->toContain('user')
->toContain('permissions_list_own')
->toContain('employee_public_data')
->toContain('list_orders')
->toContain('department_access_' . (int)$department['id'])
->not->toContain('add_order')
->not->toContain('fetch_order')
->not->toContain('list_products')
->not->toContain('search_customers')
->not->toContain('add_bookings')
->not->toContain('delete_order')
->not->toContain('statistics_orders_new')
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
@@ -1001,6 +1339,7 @@ it('includes limited employees in the regular employee list and protects raw use
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
expect($created->data()['customer_number'] ?? null)->toBe(0);
$adminSession = api_fixtures()->createUserSession([
'list_users',
@@ -1027,6 +1366,7 @@ it('includes limited employees in the regular employee list and protects raw use
);
$customerNumber = (int)($userRow['customer_number'] ?? 0);
$groupId = (int)($userRow['group_id'] ?? 0);
expect($customerNumber)->toBe(0);
api_client()->put('/users', [
'id' => $employeeId,
@@ -1090,6 +1430,7 @@ it('accepts employees without optional phone details', function (): void {
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
expect($created->data()['customer_number'] ?? null)->toBe(0);
expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue();
expect(array_key_exists('phone', $created->data()))->toBeTrue();
expect($created->data()['phone_country_code'])->toBeNull();
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('treats null-like optional product params as omitted for product detail requests', function (): void {
api_test_covers('GET /products', 'optional-params');
$product = api_fixtures()->createProduct([
'name' => 'Null Query Product',
'price' => 400,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->get(
'/products?id=' . (int)$product['id']
. '&department_id=null&customer_id=null&category_id=null&final_price=false',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id']);
expect($response->body)->not->toContain('department_access_0');
});
it('still requires department access when final product pricing uses a real department', function (): void {
api_test_covers('GET /products', 'permissions');
$department = api_fixtures()->createDepartment(['name' => 'Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Department Priced Product',
'price' => 500,
]);
$session = api_fixtures()->createUserSession(['list_products']);
api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
it('rejects invalid department ids without requesting department access zero', function (): void {
api_test_covers('GET /products', 'validation');
$product = api_fixtures()->createProduct([
'name' => 'Invalid Department Product',
'price' => 600,
]);
$session = api_fixtures()->createUserSession(['list_products']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid department_id');
expect($response->body)->not->toContain('department_access_0');
});
@@ -3,6 +3,20 @@
use helpers\xlvask_usage_log;
use objects\xlvask_usage_logs_o;
it('serializes empty ignore metadata as SQL null values for new usage logs', function (): void {
$log = new xlvask_usage_log();
$data = $log->toArray();
expect($data)
->toHaveKey('ignored_at')
->toHaveKey('ignored_by')
->toHaveKey('ignored_reason')
->and($data['ignored_at'])->toBeNull()
->and($data['ignored_by'])->toBeNull()
->and($data['ignored_reason'])->toBeNull()
->and($data['Updated'])->toBe('');
});
it('accepts persisted ignore metadata from xlvask usage log rows', function (): void {
$log = new xlvask_usage_log();
@@ -57,3 +71,21 @@ it('calculates XL Vask amount summaries without hydrating order item previews',
'primary_product_name' => 'Stor bil',
]);
});
it('formats date-only XL Vask usage import start dates for the upstream API', function (): void {
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'formatImportDateFrom');
expect($method->invoke(null, '2026-03-01'))->toBe('2026-03-01T00:00:00.000');
});
it('filters fetched XL Vask usage logs inclusively to the requested import end date', function (): void {
$keep = new xlvask_usage_log(['StartTime' => '2026-03-31T23:59:59.000']);
$drop = new xlvask_usage_log(['StartTime' => '2026-04-01T00:00:00.000']);
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'filterUsageLogsUntil');
$result = $method->invoke(null, [$keep, $drop], '2026-03-31');
expect($result)
->toHaveCount(1)
->and($result[0])->toBe($keep);
});
@@ -43,3 +43,22 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']")
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
});
it('scopes manual XL Vask usage import and automation to optional period dates', function (): void {
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($route)
->not->toBeFalse()
->and($automation)->not->toBeFalse();
$route = (string)$route;
$automation = (string)$automation;
expect($route)
->toContain("getParameter('dateFrom')")
->toContain("getParameter('dateTo')")
->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)')
->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
});