Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
c7f5c73a9e | ||
|
|
c10af48954 |
@@ -12757,6 +12757,33 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
|
||||
|
||||
/departments/daily-reports/product-targets:
|
||||
put:
|
||||
tags:
|
||||
- Departments
|
||||
summary: Set daily report product target
|
||||
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
|
||||
operationId: setDailyReportProductTarget
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Daily report product target updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/departments/daily-reports/get:
|
||||
get:
|
||||
tags:
|
||||
@@ -21566,6 +21593,36 @@ components:
|
||||
state: { type: string }
|
||||
value: { type: integer }
|
||||
out_of: { type: integer }
|
||||
target_percentage: { type: number, format: float, nullable: true }
|
||||
target_department_id: { type: integer, nullable: true }
|
||||
|
||||
DepartmentDailyReportProductTargetRequest:
|
||||
type: object
|
||||
required:
|
||||
- department_id
|
||||
- product_id
|
||||
- target_percentage
|
||||
properties:
|
||||
department_id: { type: integer }
|
||||
product_id: { type: integer }
|
||||
target_percentage:
|
||||
type: number
|
||||
format: float
|
||||
nullable: true
|
||||
|
||||
DepartmentDailyReportProductTarget:
|
||||
type: object
|
||||
properties:
|
||||
department_id: { type: integer }
|
||||
product_id: { type: integer }
|
||||
target_percentage: { type: number, format: float, nullable: true }
|
||||
|
||||
DepartmentDailyReportProductTargetResponse:
|
||||
type: object
|
||||
properties:
|
||||
success: { type: boolean, example: true }
|
||||
data:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
|
||||
|
||||
DepartmentDailyReportOverviewPayload:
|
||||
type: object
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -12768,6 +12768,33 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
|
||||
|
||||
/departments/daily-reports/product-targets:
|
||||
put:
|
||||
tags:
|
||||
- Departments
|
||||
summary: Set daily report product target
|
||||
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
|
||||
operationId: setDailyReportProductTarget
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Daily report product target updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/departments/daily-reports/get:
|
||||
get:
|
||||
tags:
|
||||
@@ -21577,6 +21604,36 @@ components:
|
||||
state: { type: string }
|
||||
value: { type: integer }
|
||||
out_of: { type: integer }
|
||||
target_percentage: { type: number, format: float, nullable: true }
|
||||
target_department_id: { type: integer, nullable: true }
|
||||
|
||||
DepartmentDailyReportProductTargetRequest:
|
||||
type: object
|
||||
required:
|
||||
- department_id
|
||||
- product_id
|
||||
- target_percentage
|
||||
properties:
|
||||
department_id: { type: integer }
|
||||
product_id: { type: integer }
|
||||
target_percentage:
|
||||
type: number
|
||||
format: float
|
||||
nullable: true
|
||||
|
||||
DepartmentDailyReportProductTarget:
|
||||
type: object
|
||||
properties:
|
||||
department_id: { type: integer }
|
||||
product_id: { type: integer }
|
||||
target_percentage: { type: number, format: float, nullable: true }
|
||||
|
||||
DepartmentDailyReportProductTargetResponse:
|
||||
type: object
|
||||
properties:
|
||||
success: { type: boolean, example: true }
|
||||
data:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
|
||||
|
||||
DepartmentDailyReportOverviewPayload:
|
||||
type: object
|
||||
|
||||
@@ -13,6 +13,7 @@ use DateTimeZone;
|
||||
use Exception;
|
||||
use objects\department_daily_report_complaints_o;
|
||||
use objects\department_daily_reports_o;
|
||||
use objects\department_variables_o;
|
||||
use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/departments/daily-reports', function () {
|
||||
@@ -850,7 +853,8 @@ class departmentDailyReportsRoute
|
||||
'overview' => $this->buildDailyReportOverview(
|
||||
[$department_id],
|
||||
(string)self::getParameter('date'),
|
||||
$date_to
|
||||
$date_to,
|
||||
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
|
||||
),
|
||||
]);
|
||||
},
|
||||
@@ -895,7 +899,8 @@ class departmentDailyReportsRoute
|
||||
$this->buildDailyReportOverview(
|
||||
$department_ids,
|
||||
(string)self::getParameter('date'),
|
||||
$date_to
|
||||
$date_to,
|
||||
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
|
||||
)
|
||||
);
|
||||
},
|
||||
@@ -906,6 +911,73 @@ class departmentDailyReportsRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->put('/departments/daily-reports/product-targets', function () {
|
||||
global $response;
|
||||
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
self::requireParameters([
|
||||
'department_id',
|
||||
'product_id',
|
||||
'target_percentage',
|
||||
]);
|
||||
|
||||
$department_id = (int)self::getParameter('department_id');
|
||||
if ($department_id <= 0) {
|
||||
$response->error('Parameter department_id must be a positive integer', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$department = (new departments_o())->select($department_id);
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
self::requireDepartmentAccess($department_id);
|
||||
|
||||
$product_id = (int)self::getParameter('product_id');
|
||||
if (!$this->isDailyReportProductId($product_id)) {
|
||||
$response->error('Invalid daily report product_id', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
|
||||
if (!$parsed_target['valid']) {
|
||||
$response->error($parsed_target['message'], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$target_percentage = $parsed_target['value'];
|
||||
$department_variables = (new department_variables_o())->selectDepartment($department_id);
|
||||
$target_key = $this->dailyReportProductTargetVariableKey($product_id);
|
||||
|
||||
if ($target_percentage === null) {
|
||||
$this->clearDailyReportProductTarget($department_variables, $target_key);
|
||||
} else {
|
||||
$department_variables->set($target_key, number_format($target_percentage, 1, '.', ''));
|
||||
}
|
||||
|
||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'Successfully updated department daily report product target');
|
||||
|
||||
$response->success([
|
||||
'department_id' => $department_id,
|
||||
'product_id' => $product_id,
|
||||
'target_percentage' => $target_percentage,
|
||||
]);
|
||||
},
|
||||
[
|
||||
self::SET_PRODUCT_TARGET_PERMISSION => 'Set department daily report product target percentages',
|
||||
'department_access_:department_id' => 'Access the department'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/product-count', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -1369,7 +1441,7 @@ class departmentDailyReportsRoute
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to): array
|
||||
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to, bool $include_product_targets = false): array
|
||||
{
|
||||
$repository = $this->dailyReportRepository();
|
||||
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
|
||||
@@ -1389,6 +1461,11 @@ class departmentDailyReportsRoute
|
||||
|
||||
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to);
|
||||
|
||||
$product_target_lookup = [];
|
||||
if ($include_product_targets && count($department_ids) === 1) {
|
||||
$product_target_lookup = $this->getDailyReportProductTargetsForDepartment((int)$department_ids[0], $product_definitions);
|
||||
}
|
||||
|
||||
return $this->assembleDailyReportOverview(
|
||||
$department_ids,
|
||||
$date,
|
||||
@@ -1399,7 +1476,8 @@ class departmentDailyReportsRoute
|
||||
$product_summary_lookup,
|
||||
$complaints_metric,
|
||||
$night_wash_metric,
|
||||
$overtime_metric
|
||||
$overtime_metric,
|
||||
$product_target_lookup
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1412,6 +1490,7 @@ class departmentDailyReportsRoute
|
||||
* @param array<string,mixed> $complaints_metric
|
||||
* @param array<string,mixed> $night_wash_metric
|
||||
* @param array<string,mixed> $overtime_metric
|
||||
* @param array<int,float> $product_target_lookup
|
||||
* @return array{
|
||||
* department_ids:array<int>,
|
||||
* date:string,
|
||||
@@ -1430,7 +1509,8 @@ class departmentDailyReportsRoute
|
||||
array $product_summary_lookup,
|
||||
array $complaints_metric,
|
||||
array $night_wash_metric,
|
||||
array $overtime_metric
|
||||
array $overtime_metric,
|
||||
array $product_target_lookup = []
|
||||
): array {
|
||||
$products = [];
|
||||
foreach ($product_definitions as $definition) {
|
||||
@@ -1448,6 +1528,12 @@ class departmentDailyReportsRoute
|
||||
'state' => 'ready',
|
||||
'value' => (int)($product_summary['quantity'] ?? 0),
|
||||
'out_of' => (int)($product_summary['out_of'] ?? 0),
|
||||
'target_percentage' => array_key_exists($product_id, $product_target_lookup)
|
||||
? (float)$product_target_lookup[$product_id]
|
||||
: null,
|
||||
'target_department_id' => array_key_exists($product_id, $product_target_lookup)
|
||||
? (int)$department_ids[0]
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1506,6 +1592,87 @@ class departmentDailyReportsRoute
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
private function isDailyReportProductId(int $product_id): bool
|
||||
{
|
||||
return in_array(
|
||||
$product_id,
|
||||
array_map(static fn(array $definition): int => (int)$definition['product_id'], $this->getDailyReportProductDefinitions()),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid:bool,value:?float,message:string}
|
||||
*/
|
||||
private function parseDailyReportProductTargetPercentage(mixed $target_percentage): array
|
||||
{
|
||||
if ($target_percentage === null) {
|
||||
return ['valid' => true, 'value' => null, 'message' => ''];
|
||||
}
|
||||
|
||||
if (is_string($target_percentage)) {
|
||||
$target_percentage = trim($target_percentage);
|
||||
if ($target_percentage === '') {
|
||||
return ['valid' => true, 'value' => null, 'message' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_int($target_percentage) && !is_float($target_percentage) && !(is_string($target_percentage) && is_numeric($target_percentage))) {
|
||||
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be numeric, null, or empty'];
|
||||
}
|
||||
|
||||
$target_percentage = round((float)$target_percentage, 1);
|
||||
if ($target_percentage < 0.0 || $target_percentage > 100.0) {
|
||||
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be between 0 and 100'];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'value' => $target_percentage, 'message' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{product_id:int,slug:string,title:string}> $product_definitions
|
||||
* @return array<int,float>
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
|
||||
{
|
||||
$department_variables = (new department_variables_o())->selectDepartment($department_id);
|
||||
$targets = [];
|
||||
|
||||
foreach ($product_definitions as $definition) {
|
||||
$product_id = (int)$definition['product_id'];
|
||||
$stored_target = $department_variables->getVariable($this->dailyReportProductTargetVariableKey($product_id));
|
||||
|
||||
if ($stored_target === null || $stored_target === '' || !is_numeric($stored_target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targets[$product_id] = round((float)$stored_target, 1);
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
protected function clearDailyReportProductTarget(department_variables_o $department_variables, string $target_key): void
|
||||
{
|
||||
$existing_targets = $department_variables->getFieldsWhere([
|
||||
'department_id' => $department_variables->department_id,
|
||||
'variable' => $target_key,
|
||||
], ['id']);
|
||||
|
||||
if (!$existing_targets) {
|
||||
return;
|
||||
}
|
||||
|
||||
department_variables_o::delete_object('department_variables', (int)$existing_targets[0]['id']);
|
||||
$department_variables->objectChanged();
|
||||
}
|
||||
|
||||
private function dailyReportProductTargetVariableKey(int $product_id): string
|
||||
{
|
||||
return 'daily_report_product_target_percentage_' . $product_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array{product_id:int,slug:string,title:string}>
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function daily_report_product_from_overview(array $overview, int $productId): array
|
||||
{
|
||||
foreach (($overview['products'] ?? []) as $product) {
|
||||
if ((int)($product['product_id'] ?? 0) === $productId) {
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
it('stores clears and permission-gates department daily report product targets', function (): void {
|
||||
api_test_covers('PUT /departments/daily-reports/product-targets', 'happy');
|
||||
api_test_covers('GET /departments/daily-reports/overview', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Daily Report Product Target ' . uniqid('', false),
|
||||
]);
|
||||
$departmentId = (int)$department['id'];
|
||||
$editorPermissions = [
|
||||
'list_department_daily_reports',
|
||||
'list_bookings',
|
||||
'set_department_daily_report_product_targets',
|
||||
'department_access_' . $departmentId,
|
||||
];
|
||||
$editorSession = api_fixtures()->createUserSession($editorPermissions);
|
||||
$viewerSession = api_fixtures()->createUserSession([
|
||||
'list_department_daily_reports',
|
||||
'list_bookings',
|
||||
'department_access_' . $departmentId,
|
||||
]);
|
||||
|
||||
try {
|
||||
$saveResponse = api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 47.55,
|
||||
], $editorSession['headers']);
|
||||
|
||||
$saveResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($saveResponse->data())->toMatchArray([
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 47.6,
|
||||
]);
|
||||
|
||||
$overviewResponse = api_client()->get(
|
||||
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
|
||||
$editorSession['headers']
|
||||
);
|
||||
|
||||
$overviewResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$editorProduct = daily_report_product_from_overview($overviewResponse->data(), 24);
|
||||
expect($editorProduct['target_percentage'])->toBe(47.6);
|
||||
expect($editorProduct['target_department_id'])->toBe($departmentId);
|
||||
|
||||
$viewerOverviewResponse = api_client()->get(
|
||||
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
|
||||
$viewerSession['headers']
|
||||
);
|
||||
|
||||
$viewerOverviewResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$viewerProduct = daily_report_product_from_overview($viewerOverviewResponse->data(), 24);
|
||||
expect($viewerProduct['target_percentage'])->toBeNull();
|
||||
expect($viewerProduct['target_department_id'])->toBeNull();
|
||||
|
||||
$clearResponse = api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => null,
|
||||
], $editorSession['headers']);
|
||||
|
||||
$clearResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($clearResponse->data())->toMatchArray([
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => null,
|
||||
]);
|
||||
|
||||
$clearedOverviewResponse = api_client()->get(
|
||||
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
|
||||
$editorSession['headers']
|
||||
);
|
||||
|
||||
$clearedProduct = daily_report_product_from_overview($clearedOverviewResponse->data(), 24);
|
||||
expect($clearedProduct['target_percentage'])->toBeNull();
|
||||
expect($clearedProduct['target_department_id'])->toBeNull();
|
||||
} finally {
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => null,
|
||||
], $editorSession['headers']);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects product target updates without permission access or valid input', function (): void {
|
||||
api_test_covers('PUT /departments/daily-reports/product-targets', 'auth');
|
||||
api_test_covers('PUT /departments/daily-reports/product-targets', 'failure');
|
||||
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$departmentId = (int)$department['id'];
|
||||
|
||||
$missingPermissionSession = api_fixtures()->createUserSession([
|
||||
'department_access_' . $departmentId,
|
||||
]);
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 50,
|
||||
], $missingPermissionSession['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['set_department_daily_report_product_targets']);
|
||||
|
||||
$missingDepartmentAccessSession = api_fixtures()->createUserSession([
|
||||
'set_department_daily_report_product_targets',
|
||||
]);
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 50,
|
||||
], $missingDepartmentAccessSession['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . $departmentId]);
|
||||
|
||||
$editorSession = api_fixtures()->createUserSession([
|
||||
'set_department_daily_report_product_targets',
|
||||
'department_access_' . $departmentId,
|
||||
]);
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 999999,
|
||||
'target_percentage' => 50,
|
||||
], $editorSession['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid daily report product_id');
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 101,
|
||||
], $editorSession['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Parameter target_percentage must be between 0 and 100');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
+5
@@ -31,10 +31,15 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
|
||||
$content = department_daily_reports_openapi_content_or_skip();
|
||||
|
||||
expect($content)->toContain('/departments/daily-reports/overview:');
|
||||
expect($content)->toContain('/departments/daily-reports/product-targets:');
|
||||
expect($content)->toContain('/superuser/departments/{id}/overview:');
|
||||
expect($content)->toContain('operationId: getDailyReportOverview');
|
||||
expect($content)->toContain('operationId: setDailyReportProductTarget');
|
||||
expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
|
||||
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
|
||||
expect($content)->toContain('DepartmentDailyReportProductTargetRequest:');
|
||||
expect($content)->toContain('set_department_daily_report_product_targets');
|
||||
expect($content)->toContain('target_percentage');
|
||||
expect($content)->toContain('SuperuserDepartmentOverviewResponse:');
|
||||
expect($content)->toContain('DepartmentDailyReportMetric:');
|
||||
expect($content)->toContain('DepartmentDailyReportProductTile:');
|
||||
|
||||
+42
@@ -123,6 +123,7 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
|
||||
public object $complaints_repository;
|
||||
public array $opening_hours = [];
|
||||
public array $departments = [];
|
||||
public array $product_targets_by_department = [];
|
||||
public array $workfeed_departments = [];
|
||||
public array $workfeed_shifts = [];
|
||||
public department_outside_hours_statistics_service $outside_hours_service;
|
||||
@@ -161,6 +162,11 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
|
||||
{
|
||||
return $this->outside_hours_service;
|
||||
}
|
||||
|
||||
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
|
||||
{
|
||||
return $this->product_targets_by_department[$department_id] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
function fake_daily_report_department(int $id, string $name, array $variables = []): object
|
||||
@@ -250,6 +256,8 @@ it('builds the overview payload from batched repository data with deterministic
|
||||
expect($overview['products'][0]['slug'])->toBe('spot-free-lastbil');
|
||||
expect($overview['products'][0]['title'])->toBe('Spot Free (Lastbil)');
|
||||
expect($overview['products'][0]['value'])->toBe(3);
|
||||
expect($overview['products'][0]['target_percentage'])->toBeNull();
|
||||
expect($overview['products'][0]['target_department_id'])->toBeNull();
|
||||
expect($overview['products'][1]['title'])->toBe('Fælg flex pr. enhed');
|
||||
expect($overview['products'][1]['value'])->toBe(2);
|
||||
expect(array_column($overview['products'], 'title'))->toBe([
|
||||
@@ -262,6 +270,38 @@ it('builds the overview payload from batched repository data with deterministic
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds product targets to single department overview payloads when requested', function (): void {
|
||||
$repository = new FakeDailyReportRepository();
|
||||
$repository->product_overview = [
|
||||
24 => ['product_id' => 24, 'quantity' => 3, 'out_of' => 14],
|
||||
25 => ['product_id' => 25, 'quantity' => 2, 'out_of' => 14],
|
||||
];
|
||||
|
||||
$route = new DepartmentDailyReportsOverviewRouteDouble();
|
||||
$route->repository = $repository;
|
||||
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
|
||||
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
|
||||
$route->product_targets_by_department = [
|
||||
7 => [
|
||||
24 => 75.5,
|
||||
25 => 0.0,
|
||||
],
|
||||
];
|
||||
|
||||
$overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[7], '2026-03-23', '2026-03-23', true]);
|
||||
$products_by_id = [];
|
||||
foreach ($overview['products'] as $product) {
|
||||
$products_by_id[$product['product_id']] = $product;
|
||||
}
|
||||
|
||||
expect($products_by_id[24]['target_percentage'])->toBe(75.5);
|
||||
expect($products_by_id[24]['target_department_id'])->toBe(7);
|
||||
expect($products_by_id[25]['target_percentage'])->toBe(0.0);
|
||||
expect($products_by_id[25]['target_department_id'])->toBe(7);
|
||||
expect($products_by_id[27]['target_percentage'])->toBeNull();
|
||||
expect($products_by_id[27]['target_department_id'])->toBeNull();
|
||||
});
|
||||
|
||||
it('marks overtime unavailable when not every selected department can be mapped to workfeed', function (): void {
|
||||
$repository = new FakeDailyReportRepository();
|
||||
|
||||
@@ -342,8 +382,10 @@ it('wires the overview route to batched repository methods and overview path', f
|
||||
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
|
||||
|
||||
expect($routeContent)->toContain('/departments/daily-reports/overview');
|
||||
expect($routeContent)->toContain('/departments/daily-reports/product-targets');
|
||||
expect($routeContent)->toContain('/superuser/departments/{id}/overview');
|
||||
expect($routeContent)->toContain('superuser_fetch_department');
|
||||
expect($routeContent)->toContain('set_department_daily_report_product_targets');
|
||||
expect($routeContent)->toContain('/departments/daily-reports/complaints');
|
||||
expect($routeContent)->toContain('outsideHoursStatisticsService');
|
||||
expect($routeContent)->toContain('dailyReportComplaintsRepository');
|
||||
|
||||
@@ -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')");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user