Compare commits

...
Author SHA1 Message Date
Jeppe B 0d2e906763 Guard wash subscription distribution SQL 2026-07-09 10:22:33 +02:00
Jeppe B a7181a4ab2 Fix edge gateway relay binding reactivation
Reactivate existing relay binding rows when a gateway/relay pair is re-added after soft deletion, avoiding duplicate uniq_edge_gateway_binding inserts. Add regression coverage for the reactivation path.
2026-07-08 18:46:33 +02:00
Jeppe Bundgaard fc6c76ad1b Add customer rule cleanup for spotfree addon products with preview and apply functionality 2026-07-08 14:40:46 +02:00
Jeppe Bundgaard 6a694f92cc Add bulk action preview and apply endpoints for collected invoices 2026-07-08 13:46:31 +02:00
Jeppe Bundgaard b7a2dc04d7 Merge remote-tracking branch 'origin/master' 2026-07-08 13:03:49 +02:00
Jeppe Bundgaard 870b88e707 Add assertError method to ApiResponse and enhance CI test failure handling 2026-07-08 13:03:33 +02:00
Jeppe B e14cddc1fb Fix API suite regressions 2026-07-08 12:54:26 +02:00
Jeppe B 31887fa8c9 Cover API CI skip prevention wiring 2026-07-08 12:32:27 +02:00
Jeppe B eac83b18a0 Preflight required extensions for API CI 2026-07-08 12:32:19 +02:00
Jeppe B 3817a37021 Make API CI fail on skipped bootstrap 2026-07-08 12:31:40 +02:00
Jeppe Bundgaard 940a3e5e9b Refactor superuser grant selection logic to prioritize the most recently updated grant 2026-07-08 12:16:22 +02:00
Jeppe Bundgaard 3221223865 Enhance vehicle summary retrieval for customers and update OpenAPI schema for subuser management grants 2026-07-08 12:15:18 +02:00
Jeppe Bundgaard b51006d9d1 Refactor subuser management payload and enhance grant deduplication logic 2026-07-08 12:13:12 +02:00
Jeppe Bundgaard 6b7592921d Add subuser permission templates service and related tests 2026-07-08 11:49:40 +02:00
Jeppe Bundgaard f26a427510 Add test for customer booking sessions to retrieve single-product final pricing with context 2026-07-08 10:41:56 +02:00
Jeppe Bundgaard 6de747252f Add department pricing normalization for order bookings 2026-07-08 10:35:59 +02:00
Jeppe Bundgaard ff225ff5e7 Refactor getTargetItems method to include customer and department parameters for improved item normalization 2026-07-08 10:35:39 +02:00
Jeppe Bundgaard dcef993f12 Implement department wash count service and refactor related reporting functions 2026-07-08 10:35:24 +02:00
Jeppe Bundgaard 23aca449f7 Add department wash count service for tracking wash counts and transaction summaries 2026-07-08 10:25:03 +02:00
Jeppe Bundgaard 31b5ba136a Add user-scoped routes for managing subusers and their grants 2026-07-08 10:24:50 +02:00
Jeppe Bundgaard f0b5479f30 Enhance department pricing functionality and improve related tests 2026-07-08 09:35:40 +02:00
Jeppe B b77efc538a Fix backend test gates and department product access 2026-07-07 22:16:37 +02:00
Jeppe Bundgaard 10d1eb5bac Refactor system search cache handling and update OpenAPI specifications for max results 2026-07-07 18:59:07 +02:00
Jeppe Bundgaard 084435e9b8 Add customer pricing permissions and update related tests 2026-07-07 17:58:39 +02:00
Jeppe Bundgaard 172a21c517 Implement department-specific customer pricing functionality 2026-07-07 17:27:56 +02:00
Jeppe Bundgaard c24428e4c7 Update ProductsApiTest to use 'user' session for department pricing tests 2026-07-07 14:38:41 +02:00
Jeppe Bundgaard bf1d6a583e Allow customers to read own attributes 2026-07-07 13:05:24 +02:00
Jeppe Bundgaard 08ac16e665 Fix customer wash certificate access and emails 2026-07-07 12:37:19 +02:00
Jeppe Bundgaard 79185a3c76 Enhance product listing for customer booking sessions: allow access to booking-visible products without requiring additional permissions 2026-07-07 12:23:56 +02:00
Jeppe Bundgaard 3a730e3507 Merge limited backoffice role permission templates 2026-07-07 03:27:39 +02:00
Jeppe Bundgaard ce43c4e064 Expose limited backoffice role permission templates 2026-07-07 03:23:42 +02:00
Jeppe B 579ddcf510 Merge pull request #307 from copenhagentruckwash/copilot/update-limited-backoffice-roles
Fix limited-backoffice role permissions and enforce department access on order mutations
2026-07-07 02:53:06 +02:00
Jeppe Bundgaard 0b342a7780 Align limited backoffice permission cap tests 2026-07-07 02:47:49 +02:00
copilot-swe-agent[bot] 57bcbaf72a Fix 11 failing API tests across 4 files 2026-07-07 00:31:43 +00:00
copilot-swe-agent[bot] d9fbba3130 Return 404 when order item not found in DELETE /order/items 2026-07-06 23:19:00 +00:00
copilot-swe-agent[bot] e4465d9d91 Improve DELETE /order/items: clearer error message, 404 when order not found 2026-07-06 23:17:50 +00:00
copilot-swe-agent[bot] 734cd13c87 Handle prepared statement failure with error response in DELETE /order/items 2026-07-06 23:16:55 +00:00
copilot-swe-agent[bot] d0f94ac549 Use prepared statements for all new DB queries in tests and route 2026-07-06 23:15:59 +00:00
copilot-swe-agent[bot] 1d25cbe21c Fix SQL injection concerns: use prepared statements in orderItemsRoute and tests 2026-07-06 23:14:34 +00:00
copilot-swe-agent[bot] 53d0636193 Fix limited-backoffice permissions and add department access restrictions 2026-07-06 23:12:01 +00:00
Jeppe B 04bb26f1b0 Merge pull request #306 from copenhagentruckwash/copilot/fix-php-api-job-failure
Fix two failing LimitedBackofficeApiTest assertions
2026-07-07 00:44:53 +02:00
copilot-swe-agent[bot] df0d4783d0 Remove search_customers and search_vehicles from raw-permission exclusion list 2026-07-06 22:37:40 +00:00
copilot-swe-agent[bot] 39c06ceab6 Address code review: restore filter passthrough and add clarifying comments 2026-07-06 22:28:45 +00:00
copilot-swe-agent[bot] 7dd428d18e Fix two failing LimitedBackofficeApiTest tests
Fix 1: Remove add_order_attachments and download_order_attachments from
the raw-permissions exclusion check in the test. These strings are valid
capability names that legitimately appear in the /limited-backoffice/roles
response, so including them in the 'should not contain' list caused a
false failure.

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
95 changed files with 9272 additions and 2445 deletions
+830 -74
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@ class customer_product_rule_service
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
/**
* @return array{rule:string,message:string}|null
@@ -52,7 +53,7 @@ class customer_product_rule_service
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
return $this->violation('restrictSpotFree');
}
@@ -101,6 +102,15 @@ class customer_product_rule_service
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function isSpotFreeProduct(int $productId, string $searchableProduct): bool
{
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
return true;
}
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
@@ -0,0 +1,44 @@
<?php
namespace classes;
/**
* Ensures additive schema for department-scoped customer price overrides.
*/
class department_customer_price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`user_id` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,353 @@
<?php
namespace classes;
use objects\department_customer_price_overrides_o;
use objects\departments_o;
use objects\products_o;
use objects\users_o;
class department_customer_pricing_service
{
/**
* @return array<string, mixed>
*/
public function getPricing(int $departmentId, int $userId): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
return [
'department' => $department,
'customer' => $customer,
'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updatePricing(int $departmentId, int $userId, array $payload): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
}
$overrides = $payload['overrides'] ?? null;
if (!is_array($overrides)) {
throw new limited_backoffice_exception('Overrides are required.', 400);
}
$normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o();
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$normalizedKeys = [];
foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
}
global $db;
$db->conn()->begin_transaction();
try {
$db->query(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
);
foreach ($normalized as $override) {
$overrideObject->setPrice(
$departmentId,
$customer['id'],
(bool)$override['is_category'],
$override['product_or_category_id'],
(int)$override['percentage'],
$override['fixed_price']
);
}
$db->conn()->commit();
} catch (\Throwable) {
$db->conn()->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
}
foreach ($normalized as $override) {
$this->recordVersion($customer, $departmentId, $override);
}
foreach ($existingOverrides as $existingOverride) {
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
if (isset($normalizedKeys[$key])) {
continue;
}
$this->recordVersion($customer, $departmentId, [
'is_category' => (bool)$existingOverride['is_category'],
'product_or_category_id' => $existingOverride['product_or_category_id'],
'percentage' => 0,
'fixed_price' => null,
]);
}
return $this->getPricing($departmentId, $customer['id']);
}
/**
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function department(int $departmentId): array
{
$department = (new departments_o())->getDepartmentById($departmentId);
if (!is_array($department) || empty($department)) {
throw new limited_backoffice_exception('Department not found', 404);
}
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'description' => (string)($department['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{id:int,customer_number:int,display_name:string}
*/
private function customer(int $userId): array
{
$customer = (new users_o())->getUserById($userId);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return [
'id' => (int)$customer->id,
'customer_number' => (int)$customer->customer_number->value(),
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
];
}
/**
* @param array<string, mixed> $department
*/
private function assertEnabled(array $department): void
{
if (!($department['custom_pricing_only'] ?? false)) {
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
'message' => 'Department customer pricing is disabled.',
'code' => 'department_customer_pricing_disabled',
'department' => $department,
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(int $departmentId, int $userId): array
{
global $db;
$sql = "
SELECT
c.`id` AS `category_id`,
c.`name` AS `category_name`,
c.`description` AS `category_description`,
p.*,
pdp.`price` AS `department_price`
FROM `department_categories` dc
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id`
AND pdp.`product_id` = p.`id`
WHERE dc.`department_id` = " . (int)$departmentId . "
AND dc.`deleted_at` IS NULL
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
$result = $db->query($sql);
$rows = $result ? $db->fetch_all($result) : [];
$customer = (new users_o())->getUserById($userId);
$categories = [];
$seen = [];
foreach ($rows as $row) {
$productId = (int)$row['id'];
if (isset($seen[$productId])) {
continue;
}
$seen[$productId] = true;
$categoryId = (int)$row['category_id'];
if (!isset($categories[$categoryId])) {
$categories[$categoryId] = [
'id' => $categoryId,
'name' => (string)$row['category_name'],
'description' => (string)($row['category_description'] ?? ''),
'products' => [],
];
}
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
if ($departmentPrice !== null) {
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
}
$categories[$categoryId]['products'][] = [
'id' => $productId,
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'category' => $categoryId,
'apply_category_discount' => (bool)$row['apply_category_discount'],
'base_price' => (int)$row['price'],
'department_price' => $departmentPrice,
'effective_price' => $effectivePrice,
'missing_department_price' => $departmentPrice === null,
];
}
return array_values($categories);
}
/**
* @param array<int, mixed> $overrides
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
*/
private function normalizeOverrides(int $departmentId, array $overrides): array
{
$normalized = [];
foreach ($overrides as $override) {
if (!is_array($override)) {
throw new limited_backoffice_exception('Invalid override payload.', 400);
}
$isCategory = (bool)($override['is_category'] ?? false);
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
if ($objectId === null || $objectId === '') {
throw new limited_backoffice_exception('Override object is required.', 400);
}
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
if ($percentage === false || $percentage < 0 || $percentage > 100) {
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
}
$fixedPrice = null;
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
if ($fixedPrice === false || $fixedPrice < 0) {
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
}
}
if ($isCategory) {
$fixedPrice = null;
$objectId = (string)$objectId;
if ($objectId !== 'global') {
$this->assertDepartmentCategory($departmentId, $objectId);
}
} else {
$objectId = (int)$objectId;
$this->assertDepartmentProduct($departmentId, $objectId);
}
if ($percentage <= 0 && $fixedPrice === null) {
continue;
}
$key = $this->overrideKey($isCategory, $objectId);
$normalized[$key] = [
'is_category' => $isCategory,
'product_or_category_id' => $objectId,
'percentage' => (int)$percentage,
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
];
}
return array_values($normalized);
}
/**
* @param array{id:int,customer_number:int,display_name:string} $customer
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
*/
private function recordVersion(array $customer, int $departmentId, array $override): void
{
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$customer['id'],
(int)$customer['customer_number'],
(bool)$override['is_category'],
(string)$override['product_or_category_id'],
(int)$override['percentage'],
date('Y-m-d H:i:s'),
'live.department_discount_override.route',
1.0,
false,
[
'route' => 'department_customer_pricing',
'department_id' => $departmentId,
],
$override['fixed_price'],
$departmentId
);
} catch (\Throwable) {
}
}
private function overrideKey(bool $isCategory, int|string $objectId): string
{
return ((int)$isCategory) . ':' . (string)$objectId;
}
private function assertDepartmentProduct(int $departmentId, int $productId): void
{
global $db;
$result = $db->query(
'SELECT p.`id`
FROM `department_categories` dc
INNER JOIN `products` p ON p.`category` = dc.`category_id`
WHERE dc.`department_id` = ' . (int)$departmentId . '
AND dc.`deleted_at` IS NULL
AND p.`id` = ' . (int)$productId . '
LIMIT 1'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Product is not available for this department.', 400);
}
}
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
{
global $db;
$categoryId = $db->escape_string($categoryId);
$result = $db->query(
"SELECT `id`
FROM `department_categories`
WHERE `department_id` = " . (int)$departmentId . "
AND `deleted_at` IS NULL
AND `category_id` = '{$categoryId}'
LIMIT 1"
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Category is not available for this department.', 400);
}
}
}
@@ -0,0 +1,252 @@
<?php
namespace classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
use Exception;
class department_wash_count_service
{
/**
* @throws Exception
*/
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT deduped.department_id,
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(*) AS wash_count
FROM (
SELECT dedupe_key,
department_id,
MIN(counted_at) AS counted_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
) deduped
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
ORDER BY deduped.department_id ASC, hour_bucket ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
}
/**
* @param array<int|string> $department_ids
* @return array{quantity:int,products:int,earnings:int,washes:int}
* @throws Exception
*/
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [
'quantity' => 0,
'products' => 0,
'earnings' => 0,
'washes' => 0,
];
}
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
COALESCE(SUM(oi.quantity), 0) AS products,
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL";
$result = $db->query($sql);
$row = is_object($result) ? $result->fetch_assoc() : null;
return [
'quantity' => (int)($row['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
'washes' => $this->countRows($date_start, $date_end, $normalized_department_ids),
];
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{id:int,department_id:int,created_at:string}>
* @throws Exception
*/
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
department_id,
MIN(counted_at) AS created_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
ORDER BY created_at ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'department_id' => (int)($row['department_id'] ?? 0),
'created_at' => (string)($row['created_at'] ?? ''),
];
}
return $rows;
}
/**
* @param array<int> $department_ids
* @throws Exception
*/
private function countRows(string $date_start, string $date_end, array $department_ids): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
{
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
o.id AS entity_id,
o.department_id,
o.created_at AS counted_at,
0 AS source_priority
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND p.is_wash = 1
UNION ALL
SELECT CASE
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
ELSE CONCAT('selfserve:', s.id)
END AS dedupe_key,
CASE
WHEN linked_o.id IS NOT NULL THEN linked_o.id
ELSE s.id
END AS entity_id,
COALESCE(linked_o.department_id, s.department_id) AS department_id,
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
1 AS source_priority
FROM selfserve_wash_sessions s
LEFT JOIN orders linked_o
ON linked_o.id = s.order_id
AND linked_o.deleted_at IS NULL
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
AND s.deleted_at IS NULL
AND s.completed_at IS NOT NULL
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
}
/**
* @param array<int|string> $ids
* @return array<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$value = (int)$id;
if ($value > 0) {
$normalized[$value] = $value;
}
}
return array_values($normalized);
}
/**
* @throws Exception
*/
private function validateDateRange(string $date_start, string $date_end): void
{
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
}
}
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic;
}
@@ -388,7 +389,7 @@ class economic_v2_distribution_service
continue;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id);
if ($discount_row === null) {
continue;
}
@@ -1528,7 +1529,7 @@ class economic_v2_distribution_service
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id);
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
$total += $line_price;
@@ -1544,14 +1545,18 @@ class economic_v2_distribution_service
return $total;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
$cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . substr($timestamp, 0, 19);
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
return $this->discount_resolution_cache[$cache_key];
}
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
$scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
? (int)$department_id
: null;
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId);
if ($direct !== null && (
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|| (int)($direct['discount'] ?? 0) > 0
@@ -1563,13 +1568,20 @@ class economic_v2_distribution_service
if ($product !== null) {
$category = (string)$product->category->value();
if ($category !== '') {
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId);
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount;
}
}
}
if ($scopedDepartmentId !== null) {
$global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId);
if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $global_discount;
}
}
return $this->discount_resolution_cache[$cache_key] = null;
}
@@ -60,6 +60,7 @@ class economic_v2_schema_bootstrap
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL DEFAULT NULL,
user_id INT NOT NULL,
customer_number INT NOT NULL,
is_category TINYINT(1) NOT NULL,
@@ -75,6 +76,7 @@ class economic_v2_schema_bootstrap
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_user (user_id),
INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -92,6 +94,14 @@ class economic_v2_schema_bootstrap
);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN department_id INT NULL DEFAULT NULL
AFTER id"
);
}
self::$initialized = true;
}
@@ -136,7 +136,8 @@ class economic_v2_versioning_service
float $confidence = 1.0,
bool $inferred = false,
array $metadata = [],
?int $fixed_price = null
?int $fixed_price = null,
?int $department_id = null
): array {
$identity = [
'user_id' => $user_id,
@@ -144,6 +145,9 @@ class economic_v2_versioning_service
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion(
@@ -240,15 +244,21 @@ class economic_v2_versioning_service
int $customer_number,
bool $is_category,
int|string $object_id,
string $timestamp
string $timestamp,
?int $department_id = null
): ?array {
$identity = [
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
$rows = $this->resolveActiveVersions(
'customer_discount_override_versions',
[
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
],
$identity,
$timestamp,
'effective_from DESC, id DESC',
1
@@ -0,0 +1,617 @@
<?php
namespace classes;
use Exception;
use objects\collected_order_invoices_o;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class invoice_collection_bulk_action_service
{
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
public const ACTION_MERGE = 'merge_collections';
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
private const PREVIEW_TTL_SECONDS = 600;
private const MAX_COLLECTIONS = 100;
private const CONFIRMATION_PHRASES = [
'da' => 'Bekræft',
'en' => 'Confirm',
'sv' => 'Bekräfta',
'no' => 'Bekreft',
'de' => 'Bestätigen',
];
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
{
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
$previewId = $this->previewId();
$preview['preview_id'] = $previewId;
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
$this->cachePreview($previewId, [
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
'locale' => $locale,
'selection_hash' => $preview['selection_hash'],
'preview' => $preview,
]);
return $preview;
}
public function apply(
string $previewId,
string $action,
array $invoiceCollectionIds,
array $options,
string $confirmationText,
int $actorUserId,
string $locale = 'da'
): array {
global $db;
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$cached = $this->getCachedPreview($previewId);
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
}
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
if (trim($confirmationText) !== $expectedConfirmation) {
throw new Exception('Confirmation text does not match.');
}
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
if (!empty($freshPreview['blockers'])) {
throw new Exception('Action cannot be applied while blockers are present.');
}
$db->conn()->begin_transaction();
try {
$result = match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
self::ACTION_QUEUE_ECONOMIC => [
'queued_invoice_collection_ids' => $invoiceCollectionIds,
'changed_count' => count($invoiceCollectionIds),
],
default => throw new Exception('Unsupported action'),
};
(new logs_o())->add(
'orderInvoices',
'global',
1,
$actorUserId,
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
);
$db->conn()->commit();
} catch (\Throwable $e) {
$db->conn()->rollback();
throw $e;
}
$this->deleteCachedPreview($previewId);
return [
...$freshPreview,
'preview' => false,
'result' => $result,
];
}
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
{
$collections = $this->loadCollections($invoiceCollectionIds);
$base = [
'action' => $action,
'preview' => true,
'confirmation_phrase' => $this->confirmationPhrase($locale),
'invoice_collection_ids' => $invoiceCollectionIds,
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
'warnings' => [],
'blockers' => [],
];
return match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
default => throw new Exception('Unsupported action'),
};
}
private function previewCleanCustomerRules(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
$rows = $this->orderItemRows((int)$collection->id);
$violatingItemIds = [];
$includedItemIds = [];
foreach ($rows as $row) {
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
(int)$row['order_id'],
(int)$row['product_id'],
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
);
if ($violation === null) {
continue;
}
$violatingItemIds[] = (int)$row['order_item_id'];
$includedItemIds[] = (int)$row['order_item_id'];
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => (string)$violation['rule'],
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
foreach ($rows as $row) {
$orderItemId = (int)$row['order_item_id'];
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
continue;
}
$includedItemIds[] = $orderItemId;
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => $orderItemId,
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => 'related_to_removed_item',
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewMerge(array $preview, array $collections, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$blockers = [];
if (count($collections) < 2) {
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
}
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
}
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
if (count($customerNumbers) !== 1) {
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
}
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
}
$ordersToMove = [];
foreach ($collections as $collection) {
if ((int)$collection->id === $targetId) {
continue;
}
foreach ($collection->getOrderIds() as $orderIdRow) {
$ordersToMove[] = [
'order_id' => (int)$orderIdRow['id'],
'source_invoice_collection_id' => (int)$collection->id,
'target_invoice_collection_id' => $targetId,
];
}
}
return [
...$preview,
'target_invoice_collection_id' => $targetId,
'orders' => $ordersToMove,
'summary' => [
'collections' => count($collections),
'orders_to_move' => count($ordersToMove),
'changed_count' => count($ordersToMove),
],
'blockers' => $blockers,
];
}
private function previewSplitByMonth(array $preview, array $collections): array
{
$items = [];
$changed = [];
$skipped = [];
foreach ($collections as $collection) {
try {
$item = [
'invoice_collection_id' => (int)$collection->id,
...$collection->previewSplitByOrderMonth(),
];
if (($item['status'] ?? '') === 'changed') {
$changed[] = $item;
} else {
$skipped[] = $item;
}
$items[] = $item;
} catch (\Throwable $e) {
$item = [
'status' => 'skipped',
'invoice_collection_id' => (int)$collection->id,
'reason' => 'not_splittable',
'message' => $e->getMessage(),
];
$skipped[] = $item;
$items[] = $item;
}
}
return [
...$preview,
'items' => $items,
'changed' => $changed,
'skipped' => $skipped,
'summary' => [
'collections' => count($collections),
'changed_count' => count($changed),
'skipped_count' => count($skipped),
],
];
}
private function previewResetHiddenPrices(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
if ((int)$row['include_in_invoice'] !== 0) {
continue;
}
$order = (new orders_o())->select((int)$row['order_id']);
$product = (new products_o())->select((int)$row['product_id']);
if (!$order->exists() || !$product->exists()) {
continue;
}
$newPrice = (int)$order->getCustomerProductPrice($product);
if ((int)$row['price'] === $newPrice) {
continue;
}
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'current_price' => (int)$row['price'],
'new_price' => $newPrice,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewQueueEconomic(array $preview, array $collections): array
{
$blockers = [];
foreach ($collections as $collection) {
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = [
'code' => 'collection_booked',
'invoice_collection_id' => (int)$collection->id,
'message' => 'Invoice collection is already booked.',
];
}
}
return [
...$preview,
'summary' => [
'collections' => count($collections),
'changed_count' => count($collections),
],
'blockers' => $blockers,
];
}
private function applyCleanCustomerRules(array $preview): array
{
global $db;
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
if ($itemIds === []) {
return ['changed_count' => 0, 'order_item_ids' => []];
}
$ids = implode(',', array_map('intval', $itemIds));
$now = date('Y-m-d H:i:s');
$safeNow = $db->escape_string($now);
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
$this->touchOrdersForItems($itemIds);
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
}
private function applyMerge(array $preview, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$moved = [];
foreach ($preview['orders'] ?? [] as $row) {
$order = (new orders_o())->select((int)$row['order_id']);
if (!$order->exists()) {
continue;
}
$order->assignToInvoiceCollection($targetId);
$moved[] = (int)$order->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
}
private function applySplitByMonth(array $preview): array
{
$changed = [];
$skipped = [];
foreach ($preview['items'] ?? [] as $item) {
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
$skipped[] = $item;
continue;
}
try {
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
} catch (\Throwable $e) {
$skipped[] = [
'invoice_collection_id' => $invoiceCollectionId,
'status' => 'skipped',
'message' => $e->getMessage(),
];
}
}
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
}
private function applyResetHiddenPrices(array $preview): array
{
$changed = [];
foreach ($preview['order_items'] ?? [] as $item) {
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
if (!$orderItem->exists()) {
continue;
}
$orderItem->price->set((int)$item['new_price']);
$orderItem->objectChanged();
$changed[] = (int)$orderItem->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
}
private function contentMutationBlockers(collected_order_invoices_o $collection): array
{
$blockers = [];
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
}
if (!empty($collection->external_id->value())) {
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
}
return $blockers;
}
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
{
global $db;
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
$sql = "
SELECT
oi.id AS order_item_id,
oi.order_id,
oi.product_id,
oi.related_item_id,
oi.include_in_invoice,
oi.price,
oi.quantity,
p.name AS product_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.invoice_collection_id = {$invoiceCollectionId}
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
{$hiddenCondition}
ORDER BY o.id ASC, oi.id ASC
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function touchOrdersForItems(array $itemIds): void
{
global $db;
if ($itemIds === []) {
return;
}
$ids = implode(',', array_map('intval', $itemIds));
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
if (!$result) {
return;
}
while ($row = $result->fetch_assoc()) {
$order = (new orders_o())->select((int)$row['order_id']);
if ($order->exists()) {
$order->objectChanged();
}
}
}
private function touchCollections(array $invoiceCollectionIds): void
{
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
if ($collection->exists()) {
$collection->objectChanged();
}
}
}
private function loadCollections(array $invoiceCollectionIds): array
{
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
$collection->requireSelected();
return $collection;
}, $invoiceCollectionIds);
}
private function collectionSummary(collected_order_invoices_o $collection): array
{
return [
'id' => (int)$collection->id,
'customer_number' => (int)$collection->customer_number->value(),
'name' => (string)$collection->name->value(),
'created_at' => (string)$collection->created_at->value(),
'closed_at' => $collection->closed_at->value(),
'booked_invoice_id' => $collection->booked_invoice_id->value(),
'external_id' => $collection->external_id->value(),
'order_count' => (int)$collection->getOrders(true),
];
}
private function normalizeAction(string $action): string
{
$action = trim($action);
if (!in_array($action, [
self::ACTION_CLEAN_CUSTOMER_RULES,
self::ACTION_MERGE,
self::ACTION_SPLIT_BY_MONTH,
self::ACTION_RESET_HIDDEN_PRICES,
self::ACTION_QUEUE_ECONOMIC,
], true)) {
throw new Exception('Invalid invoice collection bulk action.');
}
return $action;
}
private function normalizeInvoiceCollectionIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
if (is_array($id) || is_object($id) || !is_numeric($id)) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$parsed = (int)$id;
if ($parsed < 1 || $parsed > 999999999) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$normalized[$parsed] = $parsed;
}
$normalized = array_values($normalized);
sort($normalized);
if ($normalized === []) {
throw new Exception('invoice_collection_ids must contain at least one id.');
}
if (count($normalized) > self::MAX_COLLECTIONS) {
throw new Exception('Too many invoice collections selected.');
}
return $normalized;
}
private function normalizeOptions(array $options): array
{
if (isset($options['target_invoice_collection_id'])) {
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
}
ksort($options);
return $options;
}
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
{
return hash('sha256', json_encode([
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function confirmationPhrase(string $locale): string
{
$language = strtolower(substr(trim($locale), 0, 2));
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
}
private function previewId(): string
{
return bin2hex(random_bytes(16));
}
private function previewCacheKey(string $previewId): string
{
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
}
private function cachePreview(string $previewId, array $payload): void
{
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
}
private function getCachedPreview(string $previewId): ?array
{
$raw = (new redis())->get($this->previewCacheKey($previewId));
if (!$raw) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function deleteCachedPreview(string $previewId): void
{
(new redis())->delete($this->previewCacheKey($previewId));
}
}
@@ -22,6 +22,7 @@ class invoice_period_flag_service
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
private array $economicCustomerDiscountCache = [];
private array $userDisplayNameCache = [];
private array $orderItemsPreviewCache = [];
@@ -31,6 +32,7 @@ class invoice_period_flag_service
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
department_customer_price_overrides_schema_bootstrap::ensureTables();
}
public function createManualFlag(array $payload, int $userId): array
@@ -712,9 +714,21 @@ class invoice_period_flag_service
p.max_quantity_per_order,
c.name AS category_name,
pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage,
product_discount.fixed_price AS product_fixed_price,
category_discount.percentage AS category_discount_percentage
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
ELSE product_discount.percentage
END AS product_discount_percentage,
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
ELSE product_discount.fixed_price
END AS product_fixed_price,
CASE
WHEN d.custom_pricing_only = 1 THEN GREATEST(
COALESCE(department_category_discount.percentage, 0),
COALESCE(department_global_discount.percentage, 0)
)
ELSE category_discount.percentage
END AS category_discount_percentage
FROM orders o
LEFT JOIN (
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
@@ -745,6 +759,32 @@ class invoice_period_flag_service
) category_discount
ON category_discount.customer_number = o.customer_id
AND category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
FROM department_customer_price_overrides
WHERE is_category = 0
GROUP BY department_id, user_id, product_or_category_id
) department_product_discount
ON department_product_discount.department_id = o.department_id
AND department_product_discount.user_id = u.id
AND department_product_discount.product_or_category_id = p.id
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id <> 'global'
GROUP BY department_id, user_id, product_or_category_id
) department_category_discount
ON department_category_discount.department_id = o.department_id
AND department_category_discount.user_id = u.id
AND department_category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id = 'global'
GROUP BY department_id, user_id
) department_global_discount
ON department_global_discount.department_id = o.department_id
AND department_global_discount.user_id = u.id
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
AND o.deleted_at IS NULL
ORDER BY o.customer_id, o.id, oi.id";
@@ -900,7 +940,7 @@ class invoice_period_flag_service
}
$restrictedProducts = [
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
];
@@ -2058,6 +2098,11 @@ class invoice_period_flag_service
private function rowMatchesProductTerms(array $row, array $terms): bool
{
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
return true;
}
$haystack = strtolower(trim(
(string)($row['product_name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
@@ -11,6 +11,8 @@ class limited_backoffice_service
{
public const PERMISSION_ACCESS = 'limited_backoffice_access';
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
public const PERMISSION_VIEW_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_view';
public const PERMISSION_MANAGE_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_manage';
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
@@ -29,6 +31,21 @@ class limited_backoffice_service
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
];
/**
* Permissions that are always granted to managed employees when present in a role preset,
* regardless of whether the creating manager holds those permissions themselves.
*
* @var array<int, string>
*/
private const ROLE_UNCONDITIONAL_PERMISSIONS = [
'list_departments',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
];
/**
* @var array<string, array{label:string,description:string,permissions:array<int,string>}>
*/
@@ -47,6 +64,7 @@ class limited_backoffice_service
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'fetch_order',
'add_order',
@@ -89,6 +107,11 @@ class limited_backoffice_service
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
],
],
'booking_coordinator' => [
@@ -97,6 +120,7 @@ class limited_backoffice_service
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'list_bookings',
'list_own_bookings',
@@ -108,6 +132,11 @@ class limited_backoffice_service
'department_timebookings_entries_get',
'department_timebookings_entries_post',
'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
],
],
'operations_lead' => [
@@ -116,6 +145,7 @@ class limited_backoffice_service
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'fetch_order',
'add_order',
@@ -159,6 +189,9 @@ class limited_backoffice_service
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
],
@@ -169,6 +202,7 @@ class limited_backoffice_service
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'fetch_order',
'add_order',
@@ -212,10 +246,15 @@ class limited_backoffice_service
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
self::PERMISSION_ACCESS,
self::PERMISSION_MANAGE_PRICES,
self::PERMISSION_VIEW_CUSTOMER_PRICING,
self::PERMISSION_MANAGE_CUSTOMER_PRICING,
self::PERMISSION_MANAGE_EMPLOYEES,
],
],
@@ -417,6 +456,22 @@ class limited_backoffice_service
'group' => 'time_bookings',
'capability' => 'edit_time_booking_entries',
],
'list_departments' => [
'group' => 'departments',
'capability' => 'view_departments',
],
'list_department_daily_reports' => [
'group' => 'departments',
'capability' => 'view_daily_reports',
],
'list_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'list_own_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'statistics_orders_new' => [
'group' => 'reports',
'capability' => 'view_order_statistics',
@@ -433,6 +488,14 @@ class limited_backoffice_service
'group' => 'limited_backoffice',
'capability' => 'manage_department_prices',
],
self::PERMISSION_VIEW_CUSTOMER_PRICING => [
'group' => 'limited_backoffice',
'capability' => 'view_customer_pricing',
],
self::PERMISSION_MANAGE_CUSTOMER_PRICING => [
'group' => 'limited_backoffice',
'capability' => 'manage_customer_pricing',
],
self::PERMISSION_MANAGE_EMPLOYEES => [
'group' => 'limited_backoffice',
'capability' => 'manage_employee_access',
@@ -444,6 +507,7 @@ class limited_backoffice_service
*/
private const ROLE_PERMISSION_GROUP_ORDER = [
'account',
'departments',
'orders',
'products',
'customers',
@@ -452,6 +516,7 @@ class limited_backoffice_service
'scanner',
'bookings',
'time_bookings',
'notifications',
'reports',
'limited_backoffice',
];
@@ -498,6 +563,23 @@ class limited_backoffice_service
return $roles;
}
/**
* @return array<int, array{key:string,label:string,description:string,permissions:array<int,string>}>
*/
public function rolePermissionTemplates(): array
{
$templates = [];
foreach (self::ROLE_PRESETS as $key => $preset) {
$templates[] = [
'key' => $key,
'label' => $preset['label'],
'description' => $preset['description'],
'permissions' => array_values($preset['permissions']),
];
}
return $templates;
}
/**
* @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}>
@@ -725,6 +807,34 @@ class limited_backoffice_service
return $this->getDepartmentPrices($user, $departmentId);
}
/**
* @return array<string, mixed>
*/
public function getDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId): array
{
$this->assertDepartmentAccess($user, $departmentId);
return (new department_customer_pricing_service())->getPricing($departmentId, $customerUserId);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updateDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId, array $payload): array
{
$this->assertDepartmentAccess($user, $departmentId);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
return (new department_customer_pricing_service())->updatePricing($departmentId, $customerUserId, [
...$payload,
'department_id' => $departmentId,
'user_id' => $customerUserId,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -1600,6 +1710,11 @@ class limited_backoffice_service
continue;
}
if (in_array($permission, self::ROLE_UNCONDITIONAL_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if ($manager->hasPermission($permission)) {
$permissions[] = $permission;
}
@@ -73,7 +73,9 @@ class selfserve_schema_bootstrap
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number),
INDEX idx_selfserve_wash_sessions_created_at (created_at)
INDEX idx_selfserve_wash_sessions_created_at (created_at),
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
INDEX idx_selfserve_wash_sessions_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
@@ -219,6 +221,16 @@ class selfserve_schema_bootstrap
'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_department_completed',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_order',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)'
);
self::$initialized = true;
}
@@ -252,6 +264,35 @@ class selfserve_schema_bootstrap
$db->query($alterSql);
}
public static function tableHasIndex(string $table, string $index): bool
{
global $db;
$table = $db->escape_string($table);
$index = $db->escape_string($index);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT COUNT(*) AS c
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND INDEX_NAME = '$index'";
$result = $db->query($sql);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
public static function ensureIndex(string $table, string $index, string $alterSql): void
{
global $db;
if (self::tableHasIndex($table, $index)) {
return;
}
$db->query($alterSql);
}
/**
* @param array<int,string> $acceptedDataTypes
*/
@@ -0,0 +1,276 @@
<?php
namespace classes;
use modules\subusers\helpers\subusers_permission_node_key;
class subuser_permission_templates_service
{
public const TEMPLATE_DEACTIVATED = 'deactivated';
public const TEMPLATE_DRIVER = 'driver';
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
public const TEMPLATE_CUSTOM = 'custom';
/**
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
*/
private const TEMPLATES = [
self::TEMPLATE_DEACTIVATED => [
'label' => 'Deactivated',
'description' => 'Keeps the driver linked to the customer without active access.',
'enabled' => false,
'permissions' => [],
],
self::TEMPLATE_DRIVER => [
'label' => 'Driver',
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'SELFSERVE_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'ORDERS_LIST',
],
],
self::TEMPLATE_BOOKING_COORDINATOR => [
'label' => 'Booking coordinator',
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'ORDERS_LIST',
],
],
self::TEMPLATE_FLEET_ADMIN => [
'label' => 'Fleet admin',
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'VEHICLES_EDIT',
'VEHICLES_DELETE',
'VEHICLES_ADD',
'SELFSERVE_LIST',
'SELFSERVE_EDIT',
'SELFSERVE_DELETE',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
'BOOKINGS_ADD',
'ORDERS_LIST',
'ORDERS_EDIT',
'SUBUSERS_LIST',
'SUBUSERS_EDIT',
'SUBUSERS_DELETE',
'SUBUSERS_ADD',
],
],
];
/**
* @var array<string, array{group:string,capability:string}>
*/
private const PERMISSION_CAPABILITIES = [
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
];
/**
* @var array<int, string>
*/
private const GROUP_ORDER = [
'vehicles',
'selfserve',
'bookings',
'orders',
'driver_management',
];
/**
* @return array<string, mixed>
*/
public function accessModel(): array
{
return [
'templates' => $this->templates(),
'groups' => $this->groups(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function templates(): array
{
$templates = [];
foreach (self::TEMPLATES as $key => $template) {
$templates[] = [
'key' => $key,
'label' => $template['label'],
'description' => $template['description'],
'enabled' => $template['enabled'],
'permissions' => array_values($template['permissions']),
'permission_groups' => $this->permissionGroups($template['permissions']),
];
}
return $templates;
}
/**
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function groups(): array
{
$groups = [];
foreach (self::GROUP_ORDER as $group) {
$capabilities = [];
foreach (self::PERMISSION_CAPABILITIES as $capability) {
if ($capability['group'] === $group) {
$capabilities[] = $capability['capability'];
}
}
$groups[] = [
'key' => $group,
'capabilities' => array_values(array_unique($capabilities)),
];
}
return $groups;
}
/**
* @return array{enabled:bool,permissions:array<int,string>}
*/
public function expandTemplate(string $templateKey): array
{
$key = $this->normalizeTemplateKey($templateKey);
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
throw new \InvalidArgumentException('Unknown driver access template.');
}
return [
'enabled' => self::TEMPLATES[$key]['enabled'],
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
];
}
public function normalizeTemplateKey(?string $templateKey): ?string
{
if ($templateKey === null) {
return null;
}
$key = strtolower(trim($templateKey));
if ($key === self::TEMPLATE_CUSTOM) {
return self::TEMPLATE_CUSTOM;
}
return array_key_exists($key, self::TEMPLATES) ? $key : null;
}
/**
* @param array<int, string> $permissions
*/
public function classify(array $permissions, bool $enabled = true): string
{
$normalized = $this->normalizePermissions($permissions);
if (!$enabled || $normalized === []) {
return self::TEMPLATE_DEACTIVATED;
}
foreach (self::TEMPLATES as $key => $template) {
if (!$template['enabled']) {
continue;
}
if ($normalized === $this->normalizePermissions($template['permissions'])) {
return $key;
}
}
return self::TEMPLATE_CUSTOM;
}
/**
* @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function permissionGroups(array $permissions): array
{
$permissions = $this->normalizePermissions($permissions);
$groups = [];
foreach ($permissions as $permission) {
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
if ($capability === null) {
continue;
}
$group = $capability['group'];
$groups[$group] ??= [];
$groups[$group][] = $capability['capability'];
}
$payload = [];
foreach (self::GROUP_ORDER as $group) {
if (!isset($groups[$group])) {
continue;
}
$payload[] = [
'key' => $group,
'capabilities' => array_values(array_unique($groups[$group])),
];
}
return $payload;
}
/**
* @param array<int, string> $permissions
* @return array<int, string>
*/
private function normalizePermissions(array $permissions): array
{
$normalized = [];
foreach ($permissions as $permission) {
if ($permission instanceof subusers_permission_node_key) {
$permission = $permission->name;
}
if (!is_string($permission)) {
continue;
}
$permission = strtoupper(trim($permission));
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
$normalized[] = $permission;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
}
@@ -6,9 +6,8 @@ use Throwable;
class system_search_cache
{
public const PREFIX = 'system_search:v1:';
public const PREFIX = 'system_search:v2:';
public const QUERY_PREFIX = self::PREFIX . 'query:';
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
@@ -40,24 +39,6 @@ class system_search_cache
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function getIntent(string $hash): ?array
{
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
{
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
@@ -68,11 +49,6 @@ class system_search_cache
self::clearPattern(self::QUERY_PREFIX . '*');
}
public static function clearIntentCaches(): void
{
self::clearPattern(self::INTENT_PREFIX . '*');
}
public static function markDirtyTable(string $table): void
{
$table = trim($table, " `\t\n\r\0\x0B");
@@ -1,317 +0,0 @@
<?php
namespace classes;
use Exception;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_openai_intent_parser implements system_search_intent_parser_i
{
private string $apiUrl = 'https://api.openai.com/v1/responses';
private string $model = 'gpt-4.1-mini';
private float $temperature = 0.1;
private int $timeoutSeconds = 10;
private int $maxAliases = 12;
private int $maxEntityHints = 8;
private int $maxAliasLength = 64;
private int $maxHintLength = 32;
private int $maxNormalizedQueryLength = 256;
private int $maxFallbackReasonLength = 160;
/**
* @var callable|null
*/
private $transport;
private ?bool $forcedEnabled;
private ?string $forcedApiKey;
public function __construct(?callable $transport = null, ?bool $forcedEnabled = null, ?string $forcedApiKey = null)
{
$this->transport = $transport;
$this->forcedEnabled = $forcedEnabled;
$this->forcedApiKey = $forcedApiKey;
}
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
$query = trim($query);
if ($query === '') {
return $this->failed('empty_query', 'none');
}
[$enabled, $apiKey] = $this->resolveOpenAISettings();
if (!$enabled) {
return $this->failed('openai_disabled', 'none');
}
if (empty($apiKey)) {
return $this->failed('openai_missing_key', 'none');
}
$redactedQuery = self::redactSensitiveQuery($query);
$payload = $this->buildPayload($redactedQuery, $allowedEntityTypes, $taxonomy);
$cacheHash = md5(json_encode([
'q' => $redactedQuery,
'types' => $allowedEntityTypes,
'taxonomy' => $taxonomy,
'v' => 1,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getIntent($cacheHash);
if (is_array($cached) && isset($cached['success'])) {
$cached['source'] = 'cache';
return $this->normalizeResult($cached, $allowedEntityTypes);
}
try {
$raw = $this->sendRequest($payload, $apiKey);
$parsed = $this->parseResponse($raw);
$parsed['source'] = 'openai';
system_search_cache::setIntent($cacheHash, $parsed, 3600);
return $this->normalizeResult($parsed, $allowedEntityTypes);
} catch (Throwable $e) {
return $this->failed($e->getMessage(), 'openai');
}
}
public static function redactSensitiveQuery(string $query): string
{
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
$query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query;
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
$query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query;
$query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query;
$query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query;
return $query;
}
private function resolveOpenAISettings(): array
{
if ($this->forcedEnabled !== null) {
return [(bool)$this->forcedEnabled, (string)($this->forcedApiKey ?? '')];
}
try {
$openai = new openai();
$enabled = (bool)$openai->config->enabled->getVariableValue();
$apiKey = (string)$openai->config->api_key->getVariableValue();
return [$enabled, $apiKey];
} catch (Throwable) {
return [false, ''];
}
}
private function buildPayload(string $query, array $allowedEntityTypes, array $taxonomy): array
{
$taxonomyText = json_encode([
'allowed_entity_types' => array_values($allowedEntityTypes),
'taxonomy' => $taxonomy,
], JSON_UNESCAPED_UNICODE);
$prompt = "You parse user search intent into strict JSON.\n"
. "Rules:\n"
. "- Keep output concise and valid JSON only.\n"
. "- Do not invent entity types not listed in allowed_entity_types.\n"
. "- Infer what the user is trying to find, not just literal words.\n"
. "- aliases should contain user-friendly and backend-friendly equivalent terms.\n"
. "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n"
. "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n"
. "- confidence must be between 0 and 1.\n"
. "- association_hint should be true if related records likely needed.\n\n"
. "Context:\n"
. $taxonomyText . "\n\n"
. "User query:\n"
. $query;
return [
'model' => $this->model,
'temperature' => $this->temperature,
'input' => [
[
'role' => 'user',
'content' => [
['type' => 'input_text', 'text' => $prompt],
],
],
],
'text' => [
'format' => [
'type' => 'json_schema',
'name' => 'system_search_intent',
'schema' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean'],
'normalized_query' => [
'type' => 'string',
'maxLength' => $this->maxNormalizedQueryLength,
],
'aliases' => [
'type' => 'array',
'maxItems' => $this->maxAliases,
'items' => [
'type' => 'string',
'maxLength' => $this->maxAliasLength,
],
],
'entity_hints' => [
'type' => 'array',
'maxItems' => $this->maxEntityHints,
'items' => [
'type' => 'string',
'maxLength' => $this->maxHintLength,
],
],
'confidence' => [
'type' => 'number',
'minimum' => 0,
'maximum' => 1,
],
'association_hint' => ['type' => 'boolean'],
'fallback_reason' => [
'anyOf' => [
['type' => 'string', 'maxLength' => $this->maxFallbackReasonLength],
['type' => 'null'],
],
],
],
'required' => [
'success',
'normalized_query',
'aliases',
'entity_hints',
'confidence',
'association_hint',
'fallback_reason',
],
'additionalProperties' => false,
],
'strict' => true,
],
],
];
}
private function sendRequest(array $payload, string $apiKey): array
{
if ($this->transport !== null) {
$result = call_user_func($this->transport, $payload, $apiKey);
if (!is_array($result)) {
throw new Exception('Transport returned invalid payload');
}
return $result;
}
$curl = curl_init($this->apiUrl);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
]);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$raw = curl_exec($curl);
if ($raw === false) {
$error = curl_error($curl);
curl_close($curl);
throw new Exception('cURL error: ' . $error);
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new Exception('Invalid JSON from OpenAI');
}
if ($status >= 400) {
$message = $decoded['error']['message'] ?? ('OpenAI HTTP ' . $status);
throw new Exception($message);
}
return $decoded;
}
private function parseResponse(array $response): array
{
$text = $response['output'][0]['content'][0]['text'] ?? null;
if (!is_string($text) || $text === '') {
throw new Exception('Invalid response format (missing output text)');
}
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
throw new Exception('Invalid intent JSON');
}
return $decoded;
}
private function normalizeResult(array $result, array $allowedEntityTypes = []): array
{
$normalizedQuery = trim((string)($result['normalized_query'] ?? ''));
if (mb_strlen($normalizedQuery) > $this->maxNormalizedQueryLength) {
$normalizedQuery = mb_substr($normalizedQuery, 0, $this->maxNormalizedQueryLength);
}
$aliases = $this->sanitizeStringList((array)($result['aliases'] ?? []), $this->maxAliases, $this->maxAliasLength);
$entityHints = $this->sanitizeStringList((array)($result['entity_hints'] ?? []), $this->maxEntityHints, $this->maxHintLength);
if (!empty($allowedEntityTypes)) {
$entityHints = array_values(array_intersect($allowedEntityTypes, $entityHints));
}
$fallbackReason = null;
if (isset($result['fallback_reason']) && $result['fallback_reason'] !== null) {
$fallbackReason = trim((string)$result['fallback_reason']);
if (mb_strlen($fallbackReason) > $this->maxFallbackReasonLength) {
$fallbackReason = mb_substr($fallbackReason, 0, $this->maxFallbackReasonLength);
}
}
return [
'success' => (bool)($result['success'] ?? false),
'normalized_query' => $normalizedQuery,
'aliases' => $aliases,
'entity_hints' => $entityHints,
'confidence' => max(0.0, min(1.0, (float)($result['confidence'] ?? 0.0))),
'association_hint' => (bool)($result['association_hint'] ?? false),
'fallback_reason' => $fallbackReason,
'source' => (string)($result['source'] ?? 'openai'),
];
}
private function sanitizeStringList(array $values, int $maxItems, int $maxLength): array
{
$result = [];
foreach ($values as $value) {
if (!is_string($value)) {
continue;
}
$item = trim(mb_strtolower($value));
if ($item === '') {
continue;
}
if (mb_strlen($item) > $maxLength) {
$item = mb_substr($item, 0, $maxLength);
}
if (!in_array($item, $result, true)) {
$result[] = $item;
}
if (count($result) >= $maxItems) {
break;
}
}
return $result;
}
private function failed(string $reason, string $source): array
{
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => $reason,
'source' => $source,
];
}
}
@@ -2,18 +2,18 @@
namespace classes;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_service
{
private system_search_intent_parser_i $intentParser;
private int $lowConfidenceResultThreshold = 5;
private int $lowConfidenceTopScoreThreshold = 60;
private int $defaultEntityFetchLimit = 200;
private int $defaultMaxResults = 50;
private int $maxExpandedTerms = 24;
private int $maxTermLength = 64;
private int $recencyScoreTolerance = 12;
private int $minimumEffectiveScore = 45;
private int $minimumExplicitTypeScore = 20;
private int $associationSeedScoreThreshold = 80;
private array $rankingBoostByType = [
'invoices' => 35,
'orders' => 35,
@@ -35,9 +35,8 @@ class system_search_service
private array $tableColumnsCache = [];
private array $customerContextCache = [];
public function __construct(?system_search_intent_parser_i $intentParser = null)
public function __construct()
{
$this->intentParser = $intentParser ?? new system_search_openai_intent_parser();
try {
system_search_economic_customer_index::ensureTable();
system_search_document_index::ensureTable();
@@ -59,18 +58,13 @@ class system_search_service
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
$includeAssociations = (bool)($options['include_associations'] ?? true);
$debugIntent = (bool)($options['debug_intent'] ?? false);
$limit = (int)($options['limit'] ?? 50);
$offset = (int)($options['offset'] ?? 0);
if ($limit < 1) {
$limit = 50;
$maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
if ($maxResults < 1) {
$maxResults = $this->defaultMaxResults;
}
if ($limit > 200) {
$limit = 200;
}
if ($offset < 0) {
$offset = 0;
if ($maxResults > $this->defaultMaxResults) {
$maxResults = $this->defaultMaxResults;
}
$allTypes = $this->allEntityTypes();
@@ -79,11 +73,12 @@ class system_search_service
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
}
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
$terms = $this->buildExpandedTerms($this->tokenize($query));
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
$baseMeta = [
'query' => $query,
'limit' => $limit,
'offset' => $offset,
'max_results' => $maxResults,
'allowed_types' => $activeTypes,
'cache' => ['hit' => false],
];
@@ -94,7 +89,8 @@ class system_search_service
'grouped_results' => $this->groupResultsByType([]),
'meta' => [
...$baseMeta,
'total' => 0,
'returned' => 0,
'truncated' => false,
],
];
}
@@ -104,16 +100,14 @@ class system_search_service
'include' => $includeTypes,
'exclude' => $excludeTypes,
'active' => $activeTypes,
'limit' => $limit,
'offset' => $offset,
'max' => $maxResults,
'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
'v' => 12,
'v' => 13,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getQuery($queryCacheHash);
@@ -122,7 +116,6 @@ class system_search_service
return $cached;
}
$terms = $this->buildExpandedTerms($this->tokenize($query));
$entityBoost = [];
$initialResults = $this->executeLexicalSearch(
$activeTypes,
@@ -135,76 +128,23 @@ class system_search_service
$moduleConfigVisibility,
$allowedDepartmentIds
);
$intentAssociationHint = false;
$intentMeta = [
'invoked' => false,
'source' => 'none',
'status' => 'skipped',
'confidence' => 0.0,
'expanded_terms' => $terms,
'entity_hints' => [],
'fallback_reason' => null,
];
$shouldInvokeIntent = !empty($terms) && (
$this->shouldInvokeIntentParser($initialResults)
|| $this->queryLooksIntentDriven($query, $terms)
);
if ($shouldInvokeIntent) {
$intentMeta['invoked'] = true;
$taxonomy = $this->taxonomy($activeTypes);
$intent = $this->intentParser->parse($query, $activeTypes, $taxonomy);
$intentMeta['source'] = (string)($intent['source'] ?? 'none');
$intentMeta['confidence'] = (float)($intent['confidence'] ?? 0.0);
$intentMeta['fallback_reason'] = $intent['fallback_reason'] ?? null;
$intentMeta['entity_hints'] = (array)($intent['entity_hints'] ?? []);
$intentAssociationHint = (bool)($intent['association_hint'] ?? false);
if (!empty($intent['success'])) {
$intentMeta['status'] = 'ok';
$boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? [])));
foreach ($boostedTypes as $boostedType) {
$entityBoost[$boostedType] = 25;
}
$expandedTerms = $this->buildExpandedTerms([
...$terms,
...$this->tokenize((string)($intent['normalized_query'] ?? '')),
...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))),
...$this->hintAliasTerms($boostedTypes, $taxonomy),
]);
$intentMeta['expanded_terms'] = $expandedTerms;
$initialResults = $this->executeLexicalSearch(
$activeTypes,
$expandedTerms,
$entityBoost,
$ownOnlyTypes,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds
);
} else {
$intentMeta['status'] = 'fallback';
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
if ($includeAssociations) {
$customerNumbers = [];
foreach ($initialResults as $result) {
if (!isset($result['customer_number'])) {
$customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
if ($customerNumber === null || $customerNumber <= 0) {
continue;
}
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
continue;
}
$customerNumbers[] = (int)$result['customer_number'];
$customerNumbers[] = $customerNumber;
}
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
if (count($customerNumbers) > 15) {
$customerNumbers = array_slice($customerNumbers, 0, 15);
if (count($customerNumbers) > 5) {
$customerNumbers = array_slice($customerNumbers, 0, 5);
}
if (!empty($customerNumbers)) {
$associationTypes = array_values(array_intersect(
@@ -232,18 +172,21 @@ class system_search_service
}
$item['score'] = max((int)$item['score'], 35);
}
unset($item);
$associated = $this->filterRelevantResults($associated, $includeTypes);
$initialResults = $this->mergeResults($initialResults, $associated);
}
}
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$preferRecency = $this->shouldPreferRecencySort($query, $terms);
usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
$scoreA = (int)($a['score'] ?? 0);
$scoreB = (int)($b['score'] ?? 0);
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a);
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b);
$effectiveScoreA = $this->effectiveResultScore($a);
$effectiveScoreB = $this->effectiveResultScore($b);
$recencyA = $this->resultRecencyTimestamp($a);
$recencyB = $this->resultRecencyTimestamp($b);
$cancelledA = $this->isCancelledBookingResult($a);
@@ -272,20 +215,17 @@ class system_search_service
return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
});
$total = count($initialResults);
$paged = array_slice($initialResults, $offset, $limit);
$grouped = $this->groupResultsByType($paged);
$limited = array_slice($initialResults, 0, $maxResults);
$grouped = $this->groupResultsByType($limited);
$meta = [
...$baseMeta,
'total' => $total,
'returned' => count($limited),
'truncated' => count($initialResults) > count($limited),
];
if ($debugIntent) {
$meta['intent_parser'] = $intentMeta;
}
$payload = [
'results' => $paged,
'results' => $limited,
'grouped_results' => $grouped,
'meta' => $meta,
];
@@ -294,13 +234,127 @@ class system_search_service
return $payload;
}
protected function shouldInvokeIntentParser(array $results): bool
/**
* @param array<int, string> $activeTypes
* @param array<int, string> $includeTypes
* @param array<int, string> $terms
* @return array<int, string>
*/
private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array
{
if (count($results) < $this->lowConfidenceResultThreshold) {
return true;
if (empty($activeTypes) || !empty($includeTypes)) {
return $activeTypes;
}
$topScore = (int)($results[0]['score'] ?? 0);
return $topScore < $this->lowConfidenceTopScoreThreshold;
$selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes()));
foreach ($activeTypes as $entityType) {
if (in_array($entityType, $selected, true)) {
continue;
}
if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) {
$selected[] = $entityType;
}
}
return array_values(array_intersect($activeTypes, array_values(array_unique($selected))));
}
/**
* @return array<int, string>
*/
private function defaultSearchEntityTypes(): array
{
return [
'customers',
'users',
'employees',
'orders',
'order_bookings',
'bookings',
'bookings_new',
'invoices',
'vehicles',
'departments',
'products',
'objects',
];
}
/**
* @param array<int, string> $terms
*/
private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool
{
$normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' ';
if (trim($normalizedQuery) === '') {
return false;
}
$aliases = system_search_registry::taxonomyAliases()[$entityType] ?? [];
$human = str_replace('_', ' ', $entityType);
$aliases[] = $entityType;
$aliases[] = $human;
$aliases[] = rtrim($human, 's');
$aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== '')));
foreach ($aliases as $alias) {
$aliasTerms = $this->tokenize($alias);
if (empty($aliasTerms)) {
continue;
}
if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) {
return true;
}
if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
return true;
}
$aliasText = trim(mb_strtolower($alias));
if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) {
return true;
}
}
return false;
}
/**
* @param array<int, array<string, mixed>> $results
* @param array<int, string> $includeTypes
* @return array<int, array<string, mixed>>
*/
private function filterRelevantResults(array $results, array $includeTypes): array
{
$explicitTypes = array_flip($includeTypes);
$filtered = [];
foreach ($results as $result) {
$entityType = (string)($result['entity_type'] ?? '');
$score = (int)($result['score'] ?? 0);
if ($score <= 0) {
continue;
}
if (isset($explicitTypes[$entityType])) {
if ($score >= $this->minimumExplicitTypeScore) {
$filtered[] = $result;
}
continue;
}
if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) {
$filtered[] = $result;
}
}
return $filtered;
}
private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool
{
$entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
if (!in_array($entityType, ['customers', 'users'], true)) {
return false;
}
if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) {
return false;
}
return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold;
}
/**
@@ -347,20 +401,6 @@ class system_search_service
$allowedDepartmentIds,
$forcedCustomerNumbers
);
if (empty($rows)) {
$rows = $this->searchEntity(
$entityType,
$terms,
$boost,
$ownOnly,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
} else {
$rows = $this->searchEntity(
$entityType,
@@ -2647,6 +2687,11 @@ class system_search_service
return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
}
private function effectiveResultScore(array $result): int
{
return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result);
}
private function boolishTrue(mixed $value): bool
{
if (is_bool($value)) {
@@ -2706,7 +2751,7 @@ class system_search_service
return false;
}
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact intent.
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches.
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
@@ -2729,68 +2774,6 @@ class system_search_service
]);
}
/**
* @param array<int, string> $boostedTypes
* @param array<string, array<int, string>> $taxonomy
* @return array<int, string>
*/
private function hintAliasTerms(array $boostedTypes, array $taxonomy): array
{
$terms = [];
foreach ($boostedTypes as $type) {
$aliases = $taxonomy[$type] ?? [];
if (!is_array($aliases)) {
continue;
}
foreach ($aliases as $alias) {
if (!is_string($alias) || trim($alias) === '') {
continue;
}
$terms = [...$terms, ...$this->tokenize($alias)];
if (count($terms) >= 12) {
return array_slice(array_values(array_unique($terms)), 0, 12);
}
}
}
return array_slice(array_values(array_unique($terms)), 0, 12);
}
/**
* Detect natural-language style queries where intent parsing is valuable
* even when lexical score looks strong.
*
* @param array<int, string> $terms
*/
private function queryLooksIntentDriven(string $query, array $terms): bool
{
$normalized = trim(mb_strtolower($query));
if ($normalized === '' || count($terms) < 2) {
return false;
}
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
$hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1;
$hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1;
$hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1;
if ($hasStrongDomainLanguage && count($terms) >= 2) {
return true;
}
if ($hasIntentVerb && count($terms) >= 3) {
return true;
}
if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) {
return true;
}
return mb_strlen($normalized) >= 28 && count($terms) >= 4;
}
private function queryHasExplicitIdentifier(string $normalizedQuery): bool
{
if ($normalizedQuery === '') {
@@ -2985,22 +2968,6 @@ class system_search_service
return system_search_registry::allEntityTypes();
}
private function taxonomy(array $activeTypes): array
{
$aliases = system_search_registry::taxonomyAliases();
$taxonomy = [];
foreach ($activeTypes as $type) {
$resolved = $aliases[$type] ?? [];
if (empty($resolved)) {
$human = str_replace('_', ' ', $type);
$singular = rtrim($human, 's');
$resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== '')));
}
$taxonomy[$type] = $resolved;
}
return $taxonomy;
}
private function groupResultsByType(array $results): array
{
$grouped = [];
@@ -291,6 +291,12 @@ class xlvask_automation_service
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
}
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
{
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
}
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
{
$usageBag = self::productBagForAutomation($usageItems);
@@ -600,18 +606,52 @@ class xlvask_automation_service
if ($action === self::ACTION_ATTACH) {
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE;
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
}
if ($action === self::ACTION_CREATE) {
return $xlvask->config->automatic_order_creation_enabled->isTrue()
&& $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS
&& $confidence >= self::AUTO_CREATE_CONFIDENCE;
return false;
}
return false;
}
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
{
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
return false;
}
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
return false;
}
$usageItems = $context['items'] ?? [];
$orderItems = $candidateOrder['order_items'] ?? [];
return is_array($usageItems)
&& is_array($orderItems)
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
}
private function candidateOrderFromSuggestion(array $suggestion): ?array
{
$candidateOrder = $suggestion['candidate_order'] ?? null;
if (is_array($candidateOrder)) {
return $candidateOrder;
}
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
return null;
}
$decoded = json_decode($candidateOrderJson, true);
return is_array($decoded) ? $decoded : null;
}
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
{
try {
@@ -646,7 +686,7 @@ class xlvask_automation_service
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
if ($automatic) {
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.');
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
}
return $this->formatSuggestion($latest);
@@ -660,6 +700,15 @@ class xlvask_automation_service
}
}
private function automaticFeedbackReason(array $suggestion, array $context): string
{
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
return 'Automatisk accepteret: Prisoverensstemmelse.';
}
return 'Automatisk accepteret.';
}
private function createOrderFromContext(array $context): orders_o
{
$orderData = $context['proposed_order'];
-1
View File
@@ -652,7 +652,6 @@ function SystemSearchCacheMaintenanceCron(): void
if ($rebuildRequest !== null) {
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$scope = (string)($rebuildRequest['scope'] ?? 'all');
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
if ($scope === 'types' && !empty($types)) {
@@ -1,26 +0,0 @@
<?php
namespace interfaces;
interface system_search_intent_parser_i
{
/**
* Parse a natural-language search query into structured search hints.
*
* @param string $query The raw user query.
* @param array $allowedEntityTypes Entity types the caller is allowed to search.
* @param array $taxonomy Public taxonomy/aliases to improve intent parsing.
* @return array{
* success: bool,
* normalized_query: string,
* aliases: array<int, string>,
* entity_hints: array<int, string>,
* confidence: float,
* association_hint: bool,
* fallback_reason: string|null,
* source: string
* }
*/
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array;
}
@@ -131,7 +131,7 @@
"post": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate",
"description": "Requires permission `download_own_wash_certificate`. Returns a presigned download link if certificate exists and user has access.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Returns a presigned download link if certificate exists and user has access.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -146,7 +146,7 @@
"get": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate PDF",
"description": "Requires permission `download_own_wash_certificate`. Checks both legacy and current storage buckets.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Checks both legacy and current storage buckets.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -799,7 +799,6 @@ class edge_gateway_manager
$existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'relay_id' => $relayId,
'deleted_at' => null,
], ['id']);
if ($existing !== []) {
@@ -818,6 +817,7 @@ class edge_gateway_manager
$bindingObject->approved_by->set($userId);
$bindingObject->approved_at->set($this->now());
$bindingObject->metadata_json->set($bindingMetadata);
$bindingObject->deleted_at->set(null);
continue;
}
@@ -440,6 +440,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return null;
}
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
if (!$session->markCompletedIfOpen($orderId)) {
return $this->getSessionSummary((int)$session->id);
}
@@ -456,6 +458,25 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id);
}
protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void
{
try {
if ($session->wash_started_at->value() !== null) {
return;
}
$lane = (new selfserve())->lane($laneId);
$washStartedAt = (int)$lane->getWashStartTime();
if ($washStartedAt <= 0) {
return;
}
$session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt));
} catch (\Throwable) {
// Session timestamp enrichment must not block STOP completion.
}
}
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
{
$lane = (new selfserve())->lane($laneId);
@@ -490,18 +490,19 @@ class customer_vehicles_o extends db
return [];
}
//print_r($transaction_ids);
// Convert the array of transaction ids to a comma separated string
$orders = '';
$orders = [];
foreach ($transaction_ids as $transaction_id) {
// Check if the transaction is included in the invoicing.
$tmp = (new orders_o())->select((int)$transaction_id);
if (!$tmp->isIncludedInInvoicing()) {
continue; // The transaction is not included in the invoicing, skip it
}
$orders .= (int)$transaction_id . ',';
$orders[] = (int)$transaction_id;
}
// Remove the last comma
$orders = rtrim($orders, ',');
if (empty($orders)) {
return [];
}
$order_ids = implode(',', $orders);
// Get the first two transactions that are not deleted and contains at least one order item with the 'product_id' of the vehicle type for the vehicle
$query = "
SELECT o.id
@@ -509,7 +510,7 @@ class customer_vehicles_o extends db
JOIN order_items oi ON oi.order_id = o.id
WHERE o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND o.id IN ($orders)
AND o.id IN ($order_ids)
AND oi.product_id = " . (int)$this->type->value() . "
GROUP BY o.id
ORDER BY o.created_at ASC
@@ -0,0 +1,114 @@
<?php
namespace objects;
use classes\db;
use classes\department_customer_price_overrides_schema_bootstrap;
use traits\db_object_t;
class department_customer_price_overrides_o extends db
{
use db_object_t;
public function structure(): void
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->setTable('department_customer_price_overrides');
}
public function objectChanged(): void
{
}
public function setPrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId, int $percentage, ?int $fixedPrice = null): void
{
global $db;
if ($isCategory) {
$fixedPrice = null;
}
$this->removePrice($departmentId, $userId, $isCategory, $objectId);
if ($percentage <= 0 && $fixedPrice === null) {
return;
}
$objectId = $db->escape_string((string)$objectId);
$fixedPriceSql = $fixedPrice === null ? 'NULL' : (string)max(0, (int)$fixedPrice);
$sql = "INSERT INTO {$this->table} (`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
VALUES (" . (int)$departmentId . ", " . (int)$userId . ", " . (int)$isCategory . ", '{$objectId}', " . (int)$percentage . ", {$fixedPriceSql})";
$db->query($sql);
}
public function removePrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId): void
{
global $db;
$objectId = $db->escape_string((string)$objectId);
$sql = "DELETE FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
AND `is_category` = " . (int)$isCategory . "
AND `product_or_category_id` = '{$objectId}'";
$db->query($sql);
}
/**
* @return array<int, array<string, mixed>>
*/
public function getAllPrices(int $departmentId, int $userId): array
{
global $db;
$sql = "SELECT * FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
ORDER BY `is_category` DESC, `product_or_category_id` ASC";
$result = $db->query($sql);
$prices = [];
if ($result && $result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$prices[] = $this->parseRow($row);
}
}
return $prices;
}
public function getDirectPriceRow(int $departmentId, int $userId, bool $isCategory, int|string $objectId): ?array
{
global $db;
$objectId = $db->escape_string((string)$objectId);
$sql = "SELECT * FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
AND `is_category` = " . (int)$isCategory . "
AND `product_or_category_id` = '{$objectId}'
LIMIT 1";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
return $this->parseRow($result->fetch_assoc());
}
private function parseRow(array $row): array
{
$isCategory = (bool)$row['is_category'];
return [
'id' => (int)$row['id'],
'department_id' => (int)$row['department_id'],
'user_id' => (int)$row['user_id'],
'is_category' => $isCategory,
'product_or_category_id' => $isCategory ? (string)$row['product_or_category_id'] : (int)$row['product_or_category_id'],
'percentage' => (int)$row['percentage'],
'fixed_price' => array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null,
'created_at' => (string)$row['created_at'],
'updated_at' => (string)$row['updated_at'],
];
}
}
@@ -2,7 +2,10 @@
namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use classes\db;
use classes\department_wash_count_service;
use classes\object_property;
use Exception;
use traits\db_object_t;
@@ -510,8 +513,6 @@ class department_daily_reports_o extends db
public function getTransactionsOnDateWashesCount(string $date, int $department_id, string $date_to = null): int
{
return self::methodCacheWithParameters(__METHOD__, func_get_args(), 120, function() use ($date, $department_id, $date_to) {
global /** @var db $db */
$db;
// If the date_to is null, set it to the date
if ($date_to === null) {
$date_to = $date; // Making the report for an entire day
@@ -519,33 +520,7 @@ class department_daily_reports_o extends db
// Set the date time to cover the entire day
$date = date('Y-m-d 00:00:00', strtotime($date));
$date_to = date('Y-m-d 23:59:59', strtotime($date_to));
$conn = $db->conn();
// Get all the product prices, in all the orders (Not counting removed orders), for the given date and department, and sum them
$stmt = $conn->prepare(
'SELECT COUNT(DISTINCT o.id) as amount FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.department_id = ? AND DATE(o.created_at) BETWEEN ? AND ? AND o.deleted_at IS NULL AND oi.deleted_at IS NULL AND p.is_wash = 1'
);
if ($stmt) {
$stmt->bind_param('iss', $department_id, $date, $date_to); // Bind parameters (i = integer, s = string)
$stmt->execute();
$result = $stmt->get_result(); // Get the result set from the statement
$data = $result->fetch_assoc(); // Fetch the result as an associative array
// Access the "amount" field
if (!$data) {
// If there are no orders, set the amount to 0
$amount = 0;
} else {
$amount = $data['amount'];
}
$stmt->close(); // Close the statement
} else {
// Handle query preparation error
die('Query preparation failed: ' . $conn->error);
}
return (int)$amount;
return (new department_wash_count_service())->countInDateRange($date, $date_to, $department_id);
});
}
@@ -616,9 +591,6 @@ class department_daily_reports_o extends db
*/
public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array
{
global /** @var db $db */
$db;
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
if ($normalized_department_ids === []) {
return [
@@ -631,30 +603,17 @@ class department_daily_reports_o extends db
}
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
COALESCE(SUM(oi.quantity), 0) AS products,
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings,
COUNT(DISTINCT CASE WHEN p.is_wash = 1 THEN o.id END) AS washes
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL";
$result = $db->query($sql);
$row = is_object($result) ? $result->fetch_assoc() : null;
$transaction_summary = (new department_wash_count_service())->transactionSummary(
$date_start,
$date_end,
$normalized_department_ids
);
return [
'quantity' => (int)($row['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
'washes' => (int)($row['washes'] ?? 0),
'quantity' => (int)($transaction_summary['quantity'] ?? 0),
'products' => (int)($transaction_summary['products'] ?? 0),
'earnings' => (int)($transaction_summary['earnings'] ?? 0),
'washes' => (int)($transaction_summary['washes'] ?? 0),
'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to),
];
}
@@ -758,45 +717,13 @@ class department_daily_reports_o extends db
*/
public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array
{
global /** @var db $db */
$db;
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT DISTINCT o.id, o.department_id, o.created_at
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND p.is_wash = 1
ORDER BY o.created_at ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'department_id' => (int)($row['department_id'] ?? 0),
'created_at' => (string)($row['created_at'] ?? ''),
];
}
return $rows;
return (new department_wash_count_service())->listTransactions($date_start, $date_end, $normalized_department_ids);
}
/**
@@ -381,7 +381,7 @@ class order_bookings_o extends db
}
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
if ($order->hasWashCertificateAttached()) {
if ($this->getOrder()->hasWashCertificateAttached()) {
$this->sendWashCertificateToCustomer();
}
}
@@ -585,7 +585,7 @@ class order_bookings_o extends db
return !empty($this->order_id->value());
}
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, string $date = null): int
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, ?string $date = null): int
{
global /** @var db $db */
$db;
+1 -1
View File
@@ -178,7 +178,7 @@ class order_items_o extends db
// Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id);
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false, (int)$order->department_id->value());
}
// If the price is forced, set the price to the forced price
+8 -82
View File
@@ -2,8 +2,11 @@
namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use attachments\helpers\attachment_content;
use classes\db;
use classes\department_wash_count_service;
use classes\email;
use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap;
@@ -1436,7 +1439,7 @@ class orders_o extends db
throw new Exception('No user found matching the customer number in the usage log');
}
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price);
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value());
}
$order_item->notes->set(null); // Set notes for the simulated order item
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
@@ -1509,7 +1512,7 @@ class orders_o extends db
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price;
}
return $current_user->applyProductCustomerPricing((int)$product->id, $price);
return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value());
}
/**
@@ -1598,7 +1601,7 @@ class orders_o extends db
}
$unitPrice = (int)$department_price_cache[$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false);
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id);
}
$post_discount = $unitPrice * $quantity;
$total += $post_discount;
@@ -2005,33 +2008,7 @@ class orders_o extends db
public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int
{
global /** @var db $db */
$db;
// Validate the date range
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
// Prepare the SQL query to count washes in the date range for the department
$date_start = $db->escape_string($date_start);
$date_end = $db->escape_string($date_end);
// Get the amount of orders with at least one order item that has a product with the is_wash column set to true
$sql = "SELECT COUNT(DISTINCT o.id) AS wash_count
FROM $this->table o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id = $department_id
AND o.created_at BETWEEN '$date_start' AND '$date_end'
AND o.deleted_at IS NULL
AND p.is_wash = 1";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return 0; // No washes found in the date range
}
$row = $result->fetch_assoc();
return (int)$row['wash_count'];
return (new department_wash_count_service())->countInDateRange($date_start, $date_end, $department_id);
}
/**
@@ -2041,58 +2018,7 @@ class orders_o extends db
*/
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global /** @var db $db */
$db;
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
$normalized_department_ids = [];
foreach ($department_ids as $department_id) {
$normalized_id = (int)$department_id;
if ($normalized_id > 0) {
$normalized_department_ids[$normalized_id] = true;
}
}
if ($normalized_department_ids === []) {
return [];
}
$department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids)));
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT o.department_id,
DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(DISTINCT o.id) AS wash_count
FROM $this->table o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND p.is_wash = 1
GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
return (new department_wash_count_service())->countByHourForDepartments($date_start, $date_end, $department_ids);
}
/**
+4 -4
View File
@@ -326,13 +326,13 @@ class products_o extends db
return $this->getDepartmentPriceResolution($department_id)['price'];
}
public function applyCustomerDiscounts(array $products, users_o $customer): array
public function applyCustomerDiscounts(array $products, users_o $customer, ?int $departmentId = null): array
{
global $db;
// Get the customer's discounts
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer), $products);
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer, $departmentId), $products);
}
public function applyCustomerDiscount(array $product, users_o $customer): array
public function applyCustomerDiscount(array $product, users_o $customer, ?int $departmentId = null): array
{
global $db;
// Validate input
@@ -340,7 +340,7 @@ class products_o extends db
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
}
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price'], true, $departmentId);
}
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
+59 -7
View File
@@ -936,9 +936,18 @@ class users_o extends db
* @param bool $is_category If the object is a category
* @return int|null The discount percentage
*/
public function getCustomPrice(int $object_id, bool $is_category = false): int|null
public function getCustomPrice(int $object_id, bool $is_category = false, ?int $department_id = null): int|null
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
$row = (new department_customer_price_overrides_o())->getDirectPriceRow(
(int)$department_id,
(int)$this->id,
$is_category,
$object_id
);
return $row === null ? 0 : (int)$row['percentage'];
}
// Get the custom price for the product
$discount = $this->price_overrides->setUser($this->id)->getPrice($is_category, $object_id);
// If the is_category is false, check if there is a custom price for the category that the product belongs to
@@ -962,9 +971,12 @@ class users_o extends db
* - Category discount (If the product allows category inheritance of discounts)
* - Product discount (If the product has a custom price)
*/
public function getProductDiscountPercentage(int $product_id): int|null
public function getProductDiscountPercentage(int $product_id, ?int $department_id = null): int|null
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
return $this->getDepartmentScopedProductDiscountPercentage($product_id, (int)$department_id);
}
// Get the product by ID
$product = (new products_o())->select((int)$product_id);
$doesProductAllowCategoryDiscount = (bool)$product->apply_category_discount->value();
@@ -981,24 +993,36 @@ class users_o extends db
return $discount_percentage === null ? 0 : (int)$discount_percentage;
}
public function getProductFixedPrice(int $product_id): ?int
public function getProductFixedPrice(int $product_id, ?int $department_id = null): ?int
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
$row = (new department_customer_price_overrides_o())->getDirectPriceRow(
(int)$department_id,
(int)$this->id,
false,
(int)$product_id
);
if ($row === null || $row['fixed_price'] === null) {
return null;
}
return (int)$row['fixed_price'];
}
return $this->price_overrides->setUser($this->id)->getFixedPrice(false, $product_id);
}
public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true): int
public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true, ?int $department_id = null): int
{
self::requireSelected();
$fixed_price = $this->getProductFixedPrice($product_id);
$fixed_price = $this->getProductFixedPrice($product_id, $department_id);
if ($fixed_price !== null) {
return $fixed_price;
}
$discount_percentage = $use_final_price_discount_calculation
? (int)$this->getProductDiscountPercentage($product_id)
: (int)$this->getCustomPrice($product_id, false);
? (int)$this->getProductDiscountPercentage($product_id, $department_id)
: (int)$this->getCustomPrice($product_id, false, $department_id);
if ($discount_percentage <= 0) {
return $base_price;
}
@@ -1006,6 +1030,34 @@ class users_o extends db
return (int)round($base_price * (1 - ($discount_percentage / 100)));
}
private function getDepartmentScopedProductDiscountPercentage(int $product_id, int $department_id): int
{
self::requireSelected();
$overrides = new department_customer_price_overrides_o();
$product = (new products_o())->select((int)$product_id);
$discounts = [];
$productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id);
if ($productRow !== null) {
$discounts[] = (int)$productRow['percentage'];
}
if ((bool)$product->apply_category_discount->value()) {
$categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value());
if ($categoryRow !== null) {
$discounts[] = (int)$categoryRow['percentage'];
}
$globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global');
if ($globalRow !== null) {
$discounts[] = (int)$globalRow['percentage'];
}
}
return max([0, ...$discounts]);
}
/**
File diff suppressed because it is too large Load Diff
@@ -1735,7 +1735,7 @@ class InvoicingPeriodRoute
if (!array_key_exists($product_id, $discount_cache)) {
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false);
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false, $department_id);
}
$discount_cache[$product_id] = $unit_price;
}
+17 -8
View File
@@ -291,12 +291,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
// Check if the required fields are set
@@ -340,7 +339,7 @@ class bookingsRoute
);
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -348,12 +347,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
self::requireParameters(['id']);
@@ -401,7 +399,7 @@ class bookingsRoute
}
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -530,4 +528,15 @@ class bookingsRoute
]
);
}
private function requireOwnWashCertificateDownloadAccess(): void
{
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false && $user->exists() && $this->hasPermission('user')) {
return;
}
$this->requirePermission('download_own_wash_certificate');
}
}
@@ -16,11 +16,12 @@ class customerAttributes
$this->get('/customer/attributes', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('list_customer_attributes');
// Get the user object
$user = (new authentication())->get_user();
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
// Check if the request was successful
if ($user) {
if ($user || $subuser) {
// Get the query parameters from the URL
$data = $_GET;
// Check if the required fields are set
@@ -32,14 +33,19 @@ class customerAttributes
$response->error('Customer Number must be a number', 400);
}
// Check if the user exists
if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) {
$target_user = (new users_o())->automaticGetTargetUserFromRequest();
if (!$target_user->exists()) {
$response->error('Customer not found', 400);
}
if (!$this->canListTargetCustomerAttributes($target_user)) {
$this->requirePermission('list_customer_attributes');
}
// Log the incident
(new logs_o())->add('customer_attributes', 'global', 1, $user->id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
$actor_id = $user !== false ? (int)$user->id : (int)($subuser->id ?? 0);
(new logs_o())->add('customer_attributes', 'global', 1, $actor_id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
// Return the list of customer notes
$response->success(
(new users_o())->automaticGetTargetUserFromRequest()->getUserAttributes()
$target_user->getUserAttributes()
);
} else {
// Log the incident
@@ -49,7 +55,7 @@ class customerAttributes
}
},
[
'list_customer_attributes' => 'List all customer attributes'
'list_customer_attributes' => 'List all customer attributes. Authenticated customer accounts may list their own customer attributes without this permission.'
]
);
@@ -127,4 +133,29 @@ class customerAttributes
]
);
}
}
private function canListTargetCustomerAttributes(users_o $target_user): bool
{
if (!$target_user->exists()) {
return false;
}
$target_customer_number = (int)$target_user->customer_number->value();
if ($target_customer_number <= 0) {
return false;
}
$auth = new authentication();
$user = $auth->get_user();
if (
$user !== false
&& $user->exists()
&& $this->hasPermission('user')
&& (int)$user->customer_number->value() === $target_customer_number
) {
return true;
}
return $auth->get_subuser() !== false && $this->isOwnCustomerContext($target_customer_number);
}
}
@@ -44,6 +44,33 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices',
]);
$this->get('/limited-backoffice/departments/{departmentId}/customer-pricing', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING);
$departmentId = $this->routePositiveInt('departmentId');
$customerUserId = $this->queryCustomerUserId();
return $service->getDepartmentCustomerPricing($user, $departmentId, $customerUserId);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING => 'View limited backoffice department customer pricing',
]);
$this->put('/limited-backoffice/departments/{departmentId}/customer-pricing', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING);
$departmentId = $this->routePositiveInt('departmentId');
$payload = $this->requestPayload();
$customerUserId = $this->payloadCustomerUserId($payload);
return $service->updateDepartmentCustomerPricing($user, $departmentId, $customerUserId, $payload);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING => 'Manage limited backoffice department customer pricing',
]);
$this->get('/limited-backoffice/roles', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -154,4 +181,59 @@ class limitedBackofficeRoute
}
return (int)$value;
}
private function queryPositiveInt(string $name): int
{
$value = $this->fromQuery($name);
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
throw new limited_backoffice_exception('Invalid query parameter.', 400);
}
return (int)$value;
}
private function queryCustomerUserId(): int
{
if ($this->fromQuery('user_id') !== null) {
return $this->queryPositiveInt('user_id');
}
return $this->userIdFromCustomerNumber($this->queryPositiveInt('customer_number'));
}
/**
* @param array<string, mixed> $payload
*/
private function payloadPositiveInt(array $payload, string $name): int
{
$value = $payload[$name] ?? null;
if (is_int($value) && $value > 0) {
return $value;
}
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
throw new limited_backoffice_exception('Invalid request parameter.', 400);
}
return (int)$value;
}
/**
* @param array<string, mixed> $payload
*/
private function payloadCustomerUserId(array $payload): int
{
if (array_key_exists('user_id', $payload)) {
return $this->payloadPositiveInt($payload, 'user_id');
}
return $this->userIdFromCustomerNumber($this->payloadPositiveInt($payload, 'customer_number'));
}
private function userIdFromCustomerNumber(int $customerNumber): int
{
$customer = (new \objects\users_o())->getUserByCustomerNumber($customerNumber);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return (int)$customer->id;
}
}
@@ -40,11 +40,15 @@ class orderBookingRoute
$reference = self::getTargetReference(); // String | Null
$po = self::getTargetPo(); // String | Null
$pickup = self::getTargetPickup(); // Bool | Null
$items = self::getTargetItems(); // Array of order_items_o objects
$this->requireOrderBookingCreateAccess(
(int)$customer_number->customer_number->value(),
(int)$department->id
);
$items = self::getTargetItems(
true,
$customer_number,
(int)$department->id
); // Array of order_items_o objects
/**
* Input data
*/
@@ -257,7 +261,6 @@ class orderBookingRoute
$reference = self::getTargetReference(false); // String | Null
$po = self::getTargetPo(false); // String | Null
$pickup = self::getTargetPickup(false); // Bool | Null
$items = self::getTargetItems(false); // Array of order_items_o objects
$order_id_was_set = self::isParametersSet(['order_id']);
$order_id = self::getTargetOrderId(false); // Int | Null
/** Authentication */
@@ -284,6 +287,11 @@ class orderBookingRoute
$ownGuard,
'You do not have permission to edit this order booking.'
);
$items = self::getTargetItems(
false,
$customer_number ?: (new users_o())->getUserByCustomerNumber((int)$object->customer_number->value()),
$department !== null ? (int)$department->id : (int)$object->department->value()
); // Array of order_items_o objects
/**
* Update the object
*/
@@ -796,7 +804,11 @@ class orderBookingRoute
/**
* @throws Exception
*/
private function getTargetItems(bool $required = true): array|null {
private function getTargetItems(
bool $required = true,
?users_o $customer = null,
?int $department_id = null
): array|null {
global $response;
$parameter = 'items';
$error = 'Invalid items';
@@ -811,11 +823,52 @@ class orderBookingRoute
foreach ($items as $key => $item) {
self::requireType($item, self::type_array());
self::requireValidItem((array)$item, $key);
$items[$key]['name'] = (new products_o())->select((int)$item['id'])->name->value();
$items[$key] = $this->normalizeBookingItem((array)$item, $customer, $department_id);
}
return $items;
}
/**
* @throws Exception
*/
private function normalizeBookingItem(array $item, ?users_o $customer, ?int $department_id): array
{
global $response;
$product = (new products_o())->select((int)$item['id']);
if (!$product->exists()) {
$response->error('Invalid item id', 400);
}
$price = (int)$product->price->value();
if ($department_id !== null) {
$priceResolution = $product->getDepartmentPriceResolution($department_id);
$price = (int)$priceResolution['price'];
if (
$customer !== null
&& $customer->exists()
&& !products_o::priceResolutionIsCustomMissing($priceResolution)
) {
$price = $customer->applyProductCustomerPricing(
(int)$product->id,
$price,
true,
$department_id
);
}
} elseif ($customer !== null && $customer->exists()) {
$price = $customer->applyProductCustomerPricing((int)$product->id, $price);
}
return [
...$item,
'id' => (int)$product->id,
'name' => (string)$product->name->value(),
'quantity' => (int)$item['quantity'],
'price' => $price,
];
}
private function cleanReg(string $reg): string
{
// Remove all non-alphanumeric characters
@@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary;
use classes\economic_v2_compare_engine;
use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service;
use classes\invoice_collection_bulk_action_service;
use classes\invoicing_period_utils;
use classes\response;
use classes\router;
@@ -711,6 +712,105 @@ class orderInvoicesRoute
]
);
/** Collected order invoices > Bulk action preview > POST */
$this->post('/collected-invoices/bulk-actions/preview', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'PREVIEW_COLLECTED_INVOICE_BULK_ACTION', 'User tried to preview a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['action', 'invoice_collection_ids']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$preview = (new invoice_collection_bulk_action_service())->preview(
$action,
$invoice_collection_ids,
$options,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user->id,
'PREVIEW_COLLECTED_INVOICE_BULK_ACTION',
'User previewed collected invoice bulk action ' . $action . ' for ' . count($invoice_collection_ids) . ' invoice collections'
);
$response->success($preview);
},
[
'reset_collected_invoice_economic' => 'Preview collected invoice bulk cleanup and price reset actions. This is a superuser-only route.',
'move_collected_invoice' => 'Preview merging selected collected invoices. This is a superuser-only route.',
'split_collected_invoice' => 'Preview splitting selected collected invoices by order month. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Preview queueing selected collected invoices for E-Conomic. This is a superuser-only route.',
]
);
/** Collected order invoices > Bulk action apply > POST */
$this->post('/collected-invoices/bulk-actions/apply', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'APPLY_COLLECTED_INVOICE_BULK_ACTION', 'User tried to apply a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$result = (new invoice_collection_bulk_action_service())->apply(
(string)self::getParameter('preview_id'),
$action,
$invoice_collection_ids,
$options,
(string)self::getParameter('confirmation_text'),
(int)$user->id,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success($result);
},
[
'reset_collected_invoice_economic' => 'Apply collected invoice bulk cleanup and price reset actions after confirmation. This is a superuser-only route.',
'move_collected_invoice' => 'Apply merging selected collected invoices after confirmation. This is a superuser-only route.',
'split_collected_invoice' => 'Apply splitting selected collected invoices by order month after confirmation. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Apply queueing selected collected invoices for E-Conomic after confirmation. This is a superuser-only route.',
]
);
/** Collected order invoices > E-Conomic > POST (queued) */
$this->post('/collected-invoices/economic', function () {
global $response;
@@ -2521,6 +2621,20 @@ class orderInvoicesRoute
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
}
private function requireCollectedInvoiceBulkActionPermission(string $action): void
{
$permission = match ($action) {
invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES,
invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES => 'reset_collected_invoice_economic',
invoice_collection_bulk_action_service::ACTION_MERGE => 'move_collected_invoice',
invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH => 'split_collected_invoice',
invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC => 'add_collected_invoice_economic',
default => throw new Exception('Invalid invoice collection bulk action.'),
};
self::requirePermission($permission);
}
/**
* @param $collected_order_invoice
* @param users_o $users
+32 -8
View File
@@ -75,13 +75,12 @@ class orderItemsRoute
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
$customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) {
@@ -95,6 +94,9 @@ class orderItemsRoute
);
$response->error($customerRuleViolation['message'], 400);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o());
@@ -173,18 +175,38 @@ class orderItemsRoute
$this->delete('/order/items', function () {
// Require the user to be logged in
global $response;
global $response, $db;
$this->requirePermission('delete_order_items');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Get the query data
$data = $_GET;
// Check if the required fields are set
if (!isset($data['id'])) {
// Get the order item id from the query string or request body
$itemIdRaw = $this->fromRequest('id');
if ($itemIdRaw === null || $itemIdRaw === '') {
$response->error('Order Item ID is required', 400);
}
$data = ['id' => $itemIdRaw];
// Look up the order item to check department access
$itemId = (int)$data['id'];
$stmt = $db->prepare('SELECT oi.order_id FROM order_items oi WHERE oi.id = ? LIMIT 1');
if ($stmt === false) {
(new logs_o())->add('order_items', 'global', 1, 0, 'DELETE_ORDER_ITEMS', 'Database error while preparing department access check query');
$response->error('Database error while checking department access', 500);
}
$stmt->bind_param('i', $itemId);
$stmt->execute();
$orderItemRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($orderItemRow !== null) {
$orderForAccess = (new orders_o())->getOrderById((int)$orderItemRow['order_id']);
if (!$orderForAccess->exists()) {
$response->error('Order not found', 404);
}
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
} else {
$response->error('Order item not found', 404);
}
// Delete the order item
(new order_items_o())->removeOrderItem((int)$data['id']);
// Return the list of departments
@@ -261,6 +283,8 @@ class orderItemsRoute
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$data['department_id']);
// Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Get the base64 file
$base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Delete the attachment
$order->removeAttachment((int)$attachment_id);
// Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value());
// Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
}
// Admin/department path (requires edit_order)
self::requirePermission($permission_other);
/** Departmental access */
/** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the target department
self::requireDepartmentAccess((string)(int)$data['department_id']);
$order->department_id->set((int)$data['department_id']);
}
// If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{
try {
$user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
} catch (\Throwable) {
return false;
}
+119 -51
View File
@@ -20,13 +20,17 @@ class productsRoute
* Get the customer object if the customer_id parameter is provided (In the request 'customer_id')
* @return users_o|null
*/
private function getCustomerIfProvided(): ?users_o
private function getCustomerIfProvided(bool $restrictToOwnCustomer = false): ?users_o
{
$customerId = $this->getOptionalPositiveIntParameter('customer_id');
$customerId = $this->getCustomerIdIfProvided();
if ($customerId === null) {
return null;
}
if ($restrictToOwnCustomer && !$this->isOwnCustomerContext($customerId)) {
$this->emitForbidden(['list_products']);
}
try {
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
if ($customerObject->exists()) {
@@ -42,6 +46,11 @@ class productsRoute
return null;
}
private function getCustomerIdIfProvided(): ?int
{
return $this->getOptionalPositiveIntParameter('customer_id');
}
/**
* Get the department id if the department_id parameter is provided (In the request 'department_id')
* @return int|null
@@ -92,17 +101,40 @@ class productsRoute
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
}
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
private function isCustomerBookingSession(bool $hasAuthenticatedUser, bool $hasCustomerPermission, bool $isSubuserSession): bool
{
if (!$user instanceof users_o || $departmentId === null) {
return;
return ($hasAuthenticatedUser && $hasCustomerPermission) || $isSubuserSession;
}
private function canReadProductList(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool
{
return $isCustomerBookingSession || $hasListProductsPermission;
}
private function shouldRestrictCustomerBookingProducts(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool
{
return $isCustomerBookingSession && !$hasListProductsPermission;
}
private function canUseCustomerBookingDepartmentPricing(bool $isCustomerBookingSession, bool $useFinalPrice, ?int $customerId): bool
{
if (!$isCustomerBookingSession || !$useFinalPrice) {
return false;
}
if ($this->hasPermission('superuser_fetch_department')) {
return;
}
return $customerId === null || $this->isOwnCustomerContext($customerId);
}
$this->requirePermission('department_access_' . $departmentId);
private function isBookingVisibleProduct(array $product): bool
{
return (bool)($product['display_in_booking_form'] ?? false);
}
private function filterProductsVisibleOnBookingForm(array $products): array
{
return array_values(array_filter($products, function ($product): bool {
return is_array($product) && $this->isBookingVisibleProduct($product);
}));
}
/**
@@ -140,7 +172,7 @@ class productsRoute
// Check if the customer is set
if ($customer !== null) {
// Apply the customers unique discounts
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
$products = (new products_o())->applyCustomerDiscounts($products, $customer, $departmentId);
} else {
$products = products_o::stripDepartmentPriceSources($products);
}
@@ -178,30 +210,44 @@ class productsRoute
global $response;
$permission_node = 'list_products';
$isProductDetailsRestricted = true;
if ($this->isAuthenticated()) {
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
$hasAuthenticatedUser = $user !== false && $user !== null;
$isSubuserSession = $subuser !== false;
$hasCustomerPermission = $hasAuthenticatedUser ? self::hasPermission('user') : false;
$isCustomerBookingSession = $this->isCustomerBookingSession($hasAuthenticatedUser, $hasCustomerPermission, $isSubuserSession);
$hasListProductsPermission = $hasAuthenticatedUser ? self::hasPermission($permission_node) : false;
if ($hasAuthenticatedUser || $isSubuserSession) {
$isProductDetailsRestricted = false;
$this->requirePermission($permission_node);
if (!$this->canReadProductList($isCustomerBookingSession, $hasListProductsPermission)) {
$this->emitForbidden([$permission_node]);
}
}
// Get the user object
$user = (new authentication())->get_user();
// Set the user id to 0 if guest
$responsibleUserId = $isProductDetailsRestricted ? 0 : $user->id;
function parseProduct($product, $isGuest): array
{
$responsibleUserId = $hasAuthenticatedUser ? (int)$user->id : 0;
$parseProduct = function ($product, $isGuest, $onlyBookingVisible = false): array {
$addons = (new product_options_o())->getProductOptions($product['id']);
if ($onlyBookingVisible) {
$addons = array_values(array_filter($addons, function ($option): bool {
return (bool)($option['product']['display_in_booking_form'] ?? false);
}));
}
$tmpProduct = [
'id' => (int)$product['id'],
'name' => (string)$product['name'],
'description' => (string)$product['description'],
'price' => (int)$product['price'],
'subscription_allowed' => (boolean)$product['subscription_allowed'],
'subscription_allowed' => (bool)$product['subscription_allowed'],
'category' => (int)$product['category'],
'piktogram' => (string)$product['piktogram'],
'economic_product_id' => (int)$product['economic_product_id'],
'apply_category_discount' => (boolean)$product['apply_category_discount'],
'apply_category_discount' => (bool)$product['apply_category_discount'],
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'],
'addons' => (new product_options_o())->getProductOptions($product['id']),
'addons' => $addons,
'is_wash' => (bool)$product['is_wash'],
'display_in_booking_form' => (bool)$product['display_in_booking_form'],
'order_priority' => (int)$product['order_priority'],
@@ -236,19 +282,27 @@ class productsRoute
];
}
return $isGuest ? $tmpProductGuest : $tmpProduct;
}
};
// Check if the request was successful
if ($user || $isProductDetailsRestricted) {
if ($hasAuthenticatedUser || $isSubuserSession || $isProductDetailsRestricted) {
// Define the variables
$customer = $this->getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$restrictCustomerBookingProducts = $this->shouldRestrictCustomerBookingProducts($isCustomerBookingSession, $hasListProductsPermission);
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
$customerId = $this->getCustomerIdIfProvided();
$customer = $this->getCustomerIfProvided($restrictCustomerBookingProducts); // 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
if (
$departmentId !== null
&& !$restrictCustomerBookingProducts
&& !$this->canUseCustomerBookingDepartmentPricing($isCustomerBookingSession, $useFinalPrice, $customerId)
) {
self::requireDepartmentAccess((string)$departmentId);
}
$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 ($useFinalPrice) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Determine the products to return
if ($category) {
// Get products in the category
@@ -271,21 +325,24 @@ class productsRoute
} else {
// Get all products
$products = (array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
function ($product) use ($isProductDetailsRestricted, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted);
}
);
}
if ($restrictCustomerBookingProducts && !$productId) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Return all products, with the department pricing and customer discounts applied
//$response->success(
// array_map(function ($product) {
// return parseProduct($product);
// }, self::parseProductsPrice($products, $customer, $departmentId))
//);
$result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted) {
$productArray = parseProduct($product, $isProductDetailsRestricted);
$result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
$productArray = $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
// Get the price of the product with the department pricing and customer discounts applied
$productArray = parseProduct(self::parseProductsPrice([$productArray], $customer, $departmentId)[0], $isProductDetailsRestricted);
$productArray = $parseProduct(self::parseProductsPrice([$productArray], $customer, $departmentId)[0], $isProductDetailsRestricted, $restrictCustomerBookingProducts);
// Get the options for the product
$productArray['addons'] = self::parseOptionsPrice($productArray['addons'], $customer, $departmentId);
// Return the product with the updated price
@@ -299,9 +356,13 @@ class productsRoute
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed product with id ' . $response->getRequestParameter('id'));
// Return the product
$product = (new products_o())->select((int)self::getParameter('id'))->asArray();
if ($restrictCustomerBookingProducts && !$this->isBookingVisibleProduct($product)) {
$response->success([]);
}
$response->success(
parseProduct(
(new products_o())->select((int)self::getParameter('id'))->asArray(), $isProductDetailsRestricted
$parseProduct(
$product, $isProductDetailsRestricted, $restrictCustomerBookingProducts
)
);
}
@@ -312,15 +373,17 @@ class productsRoute
(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($category);
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Check if the department_id is set
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
}
$response->success(
array_map(function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
array_map(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
}, $products)
);
}
@@ -328,32 +391,37 @@ class productsRoute
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Get all product ids contained in a category attached to the department
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
// Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) {
return $product->id;
}, $departmentSpecificProducts);
$products = (array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
// Only include products that are in the department specific product ids
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
},
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
);
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Return the list of products
$response->success(
(new products_o())->applyDepartmentPricing((array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted, $departmentSpecificProductIds) {
// Only include products that are in the department specific product ids
return parseProduct($product, $isProductDetailsRestricted);
},
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
), $departmentId)
(new products_o())->applyDepartmentPricing($products, $departmentId)
);
}
// Return the list of products
$response->success(
(new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
})
);
$products = (new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
});
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm((array)$products);
}
$response->success($products);
} else {
// Log the incident
(new logs_o())->add('products', 'global', 1, 0, 'LIST_PRODUCTS', 'No user found, or invalid session');
@@ -362,7 +430,7 @@ class productsRoute
}
},
[
'list_products' => 'List all products'
'list_products' => 'List all products. Authenticated customer booking sessions may read booking-visible products without the permission.'
]
);
+21 -1
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o;
use objects\logs_o;
use traits\route_t;
@@ -106,6 +107,25 @@ class rolesRoute
]
);
self::get('/roles/limited-backoffice-permission-templates', function () {
global $response;
self::requirePermission('superuser');
self::requirePermission('add_role_permission');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
} else {
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
$response->error('Invalid session', 400);
}
},
[
'superuser' => 'Access the superuser interface',
'add_role_permission' => 'Add a permission to a role'
]
);
self::post('/roles/permissions', function () {
// Require the user to be logged in
global $response;
@@ -182,4 +202,4 @@ class rolesRoute
]
);
}
}
}
+522 -35
View File
@@ -6,6 +6,7 @@ use classes\authentication;
use classes\economic;
use classes\gatewayapi;
use classes\response;
use classes\subuser_permission_templates_service;
use classes\virkdata;
use Exception;
use modules\virkdata\helpers\virkdata_response;
@@ -120,6 +121,27 @@ class subusersRoute
return array_values(array_unique($permissions));
}
/**
* @return array{enabled:bool,permissions:array<int,string>}|null
*/
private function parseAccessTemplatePayload(): ?array
{
global $response;
if (!self::isParametersSet(['permission_template_key'])) {
return null;
}
$templateKey = (string)self::getParameter('permission_template_key');
try {
return (new subuser_permission_templates_service())->expandTemplate($templateKey);
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
return null;
}
private function normalizeOptionalString(mixed $value): ?string
{
if ($value === null) {
@@ -337,6 +359,7 @@ class subusersRoute
{
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
$templateService = new subuser_permission_templates_service();
$setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired;
@@ -370,6 +393,8 @@ class subusersRoute
'grant_note' => $grant ? $grant->note->value() : null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'access_state' => $accessState,
];
}
@@ -429,8 +454,8 @@ class subusersRoute
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'g.`billing_customer_number`',
'grant_id' => 'g.`id`',
'customer_number' => 'customer_number_sort',
'grant_id' => 'grant_id_sort',
'name' => 's.`name`',
];
@@ -462,10 +487,10 @@ class subusersRoute
$statement->bind_param($types, ...$refs);
}
private function buildSuperuserSubuserManagementPayload(array $row): array
private function buildSuperuserGrantPayload(array $row, bool $setupRequired): array
{
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
$templateService = new subuser_permission_templates_service();
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$accessState = 'inactive';
@@ -475,6 +500,44 @@ class subusersRoute
$accessState = 'disabled';
}
return [
'grant_id' => (int)$row['grant_id'],
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'access_state' => $accessState,
];
}
private function buildSuperuserSubuserManagementPayload(array $row, array $grantRows = []): array
{
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
if ($grantRows === [] && !empty($row['grant_id'])) {
$grantRows = [$row];
}
$grants = array_map(
fn (array $grantRow): array => $this->buildSuperuserGrantPayload($grantRow, $setupRequired),
$this->dedupeSuperuserGrantRows($grantRows)
);
$primaryGrant = $this->selectPrimarySuperuserGrant($grants);
$accessState = 'inactive';
if (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'active') !== []) {
$accessState = 'active';
} elseif (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'pending_setup') !== []) {
$accessState = 'pending_setup';
} elseif ($grants !== []) {
$accessState = 'disabled';
}
return [
'id' => (int)$row['id'],
'username' => $row['username'] ?? null,
@@ -490,20 +553,178 @@ class subusersRoute
'invite_accepted' => !$setupRequired,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_id' => (int)$row['grant_id'],
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'customer_number' => $primaryGrant['customer_number'] ?? null,
'customer_name' => $primaryGrant['customer_name'] ?? null,
'grant_id' => $primaryGrant['grant_id'] ?? null,
'grant_enabled' => $primaryGrant['grant_enabled'] ?? false,
'grant_note' => $primaryGrant['grant_note'] ?? null,
'grant_permissions' => $primaryGrant['grant_permissions'] ?? [],
'permissions' => $primaryGrant['permissions'] ?? [],
'permission_template_key' => $primaryGrant['permission_template_key'] ?? subuser_permission_templates_service::TEMPLATE_DEACTIVATED,
'permission_groups' => $primaryGrant['permission_groups'] ?? [],
'grant_created_at' => $primaryGrant['grant_created_at'] ?? null,
'grant_updated_at' => $primaryGrant['grant_updated_at'] ?? null,
'grants' => $grants,
'grant_count' => count($grants),
'customer_numbers' => array_values(array_unique(array_map(
static fn (array $grant): int => (int)$grant['customer_number'],
$grants
))),
'access_state' => $accessState,
];
}
private function listSuperuserSubusers(): array
private function dedupeSuperuserGrantRows(array $grantRows): array
{
$byCustomer = [];
foreach ($grantRows as $grantRow) {
$customerNumber = (int)($grantRow['customer_number'] ?? 0);
if ($customerNumber <= 0) {
continue;
}
$existing = $byCustomer[$customerNumber] ?? null;
if ($existing === null || $this->compareSuperuserGrantRows($grantRow, $existing) < 0) {
$byCustomer[$customerNumber] = $grantRow;
}
}
$deduped = array_values($byCustomer);
usort($deduped, fn (array $left, array $right): int => $this->compareSuperuserGrantRows($left, $right));
return $deduped;
}
private function compareSuperuserGrantRows(array $left, array $right): int
{
$leftEnabled = (int)($left['grant_enabled'] ?? 0);
$rightEnabled = (int)($right['grant_enabled'] ?? 0);
if ($leftEnabled !== $rightEnabled) {
return $rightEnabled <=> $leftEnabled;
}
$leftCustomer = (int)($left['customer_number'] ?? 0);
$rightCustomer = (int)($right['customer_number'] ?? 0);
if ($leftCustomer !== $rightCustomer) {
return $leftCustomer <=> $rightCustomer;
}
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
}
private function selectPrimarySuperuserGrant(array $grants): ?array
{
if ($grants === []) {
return null;
}
$sorted = $grants;
usort($sorted, static function (array $left, array $right): int {
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
$leftCreated = strtotime((string)($left['grant_created_at'] ?? '')) ?: 0;
$rightCreated = strtotime((string)($right['grant_created_at'] ?? '')) ?: 0;
if ($leftCreated !== $rightCreated) {
return $rightCreated <=> $leftCreated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
});
return $sorted[0];
}
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function resolveSuperuserSubuserTargetUser(int $userId): array
{
global $response;
$targetUser = (new users_o())->select($userId);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
$targetUser->getObjectProperties();
$customerNumber = (int)$targetUser->customer_number->value();
if ($customerNumber <= 0) {
$response->error('Selected user does not have a customer number', 400);
}
$customerName = $targetUser->display_name->value();
if (!is_string($customerName) || trim($customerName) === '') {
$customerName = $this->resolveCustomerName($customerNumber);
}
return [
'user_id' => (int)$targetUser->id,
'customer_number' => $customerNumber,
'customer_name' => $customerName,
];
}
private function buildSubuserSummaryForCustomer(int $customerNumber): array
{
global $db;
$statement = $db->conn->prepare("
SELECT
COUNT(*) AS `total`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') <> '' THEN 1 ELSE 0 END) AS `active`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') = '' THEN 1 ELSE 0 END) AS `pending_setup`,
SUM(CASE WHEN g.`enabled` = 0 THEN 1 ELSE 0 END) AS `disabled`
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
WHERE g.`deleted_at` IS NULL
AND g.`billing_customer_number` = ?
");
if ($statement === false) {
throw new Exception('Failed to prepare subuser summary query: ' . $db->conn->error);
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$row = $result->fetch_assoc() ?: [];
$statement->close();
return [
'total' => (int)($row['total'] ?? 0),
'active' => (int)($row['active'] ?? 0),
'pending_setup' => (int)($row['pending_setup'] ?? 0),
'disabled' => (int)($row['disabled'] ?? 0),
];
}
private function addUserScopedSubuserMeta(array $targetUser): void
{
global $response;
$response->add_meta('user_context', $targetUser);
$response->add_meta('subusers_summary', $this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}
private function listSuperuserSubusers(?int $customerNumber = null): array
{
global $db, $response;
@@ -521,6 +742,11 @@ class subusersRoute
if (!$includeNonEnabled) {
$where[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$where[] = 'g.`billing_customer_number` = ?';
$params[] = $customerNumber;
$types .= 'i';
}
if ($pagination['search'] !== null) {
$where[] = "(
@@ -532,7 +758,12 @@ class subusersRoute
OR CAST(s.`phone` AS CHAR) LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` LIKE ?
OR u.`display_name` LIKE ?
OR EXISTS (
SELECT 1
FROM `users` search_u
WHERE search_u.`customer_number` = g.`billing_customer_number`
AND search_u.`display_name` LIKE ?
)
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) {
@@ -545,10 +776,9 @@ class subusersRoute
$fromSql = "
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number`
";
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
$countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
@@ -559,7 +789,7 @@ class subusersRoute
$total = (int)($countResult->fetch_assoc()['count'] ?? 0);
$countStatement->close();
$dataSql = "
$pageSql = "
SELECT
s.`id`,
s.`username`,
@@ -572,31 +802,100 @@ class subusersRoute
s.`created_at`,
s.`updated_at`,
s.`suspended_at`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`,
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
u.`display_name` AS `customer_name`
MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`,
MIN(g.`billing_customer_number`) AS `customer_number_sort`,
MAX(g.`id`) AS `grant_id_sort`
$fromSql
$whereSql
GROUP BY
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ?
";
$dataStatement = $db->conn->prepare($dataSql);
if ($dataStatement === false) {
$pageStatement = $db->conn->prepare($pageSql);
if ($pageStatement === false) {
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
}
$dataParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams);
$dataStatement->execute();
$result = $dataStatement->get_result();
$pageParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($pageStatement, $types . 'ii', $pageParams);
$pageStatement->execute();
$result = $pageStatement->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);
$dataStatement->close();
$pageStatement->close();
$subuserIds = array_map(static fn (array $row): int => (int)$row['id'], $rows);
$grantRowsBySubuserId = [];
if ($subuserIds !== []) {
$placeholders = implode(',', array_fill(0, count($subuserIds), '?'));
$grantWhere = [
'g.`deleted_at` IS NULL',
'g.`subuser` IN (' . $placeholders . ')',
];
$grantParams = $subuserIds;
$grantTypes = str_repeat('i', count($subuserIds));
if (!$includeNonEnabled) {
$grantWhere[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$grantWhere[] = 'g.`billing_customer_number` = ?';
$grantParams[] = $customerNumber;
$grantTypes .= 'i';
}
$grantSql = "
SELECT
g.`subuser` AS `subuser_id`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`
FROM `subuser_grants` g
WHERE " . implode(' AND ', $grantWhere) . "
ORDER BY
g.`subuser` ASC,
g.`enabled` DESC,
g.`billing_customer_number` ASC,
COALESCE(g.`updated_at`, g.`created_at`) DESC,
g.`id` DESC
";
$grantStatement = $db->conn->prepare($grantSql);
if ($grantStatement === false) {
throw new Exception('Failed to prepare subuser grant list query: ' . $db->conn->error);
}
$this->bindStatementParameters($grantStatement, $grantTypes, $grantParams);
$grantStatement->execute();
$grantResult = $grantStatement->get_result();
$grantRows = $grantResult->fetch_all(MYSQLI_ASSOC);
$grantStatement->close();
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grantRow): int => (int)($grantRow['customer_number'] ?? 0),
$grantRows
));
foreach ($grantRows as $grantRow) {
$subuserId = (int)$grantRow['subuser_id'];
$customerNumberForGrant = (int)$grantRow['customer_number'];
$grantRow['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
$grantRowsBySubuserId[$subuserId] ??= [];
$grantRowsBySubuserId[$subuserId][] = $grantRow;
}
}
$response->paginate(
(int)$pagination['page'],
@@ -607,7 +906,107 @@ class subusersRoute
[$pagination['order_field'] => $pagination['order_direction']]
);
return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows);
return array_map(
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, $grantRowsBySubuserId[(int)$row['id']] ?? []),
$rows
);
}
private function getGrantForScopedUserOrFail(int $grantId, int $customerNumber): subuser_grants_o
{
global $response;
$grant = (new subuser_grants_o())->select($grantId);
if (!$grant->exists()) {
$response->error('Subuser grant not found', 404);
}
$grant->getObjectProperties();
if ((int)$grant->billing_customer_number->value() !== $customerNumber || $grant->deleted_at->value() !== null) {
$response->error('Subuser grant not found for selected user', 404);
}
return $grant;
}
private function patchScopedSubuserGrant(int $grantId, int $customerNumber): void
{
global $response;
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
$updates = [];
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$updates['enabled'] = $templateAccess['enabled'];
$updates['permissions'] = $templateAccess['permissions'];
}
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($tmp === null) {
$response->error('Invalid enabled value', 400);
}
$updates['enabled'] = (bool)$tmp;
}
if (self::isParametersSet(['note'])) {
$note = $this->normalizeOptionalString(self::getParameter('note'));
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$updates['note'] = $note;
}
if (self::isParametersSet(['permissions'])) {
$updates['permissions'] = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
}
if ($updates === []) {
$response->error('No fields to update', 400);
}
try {
$grant->update($updates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$updatedGrant = (new subuser_grants_o())->select($grantId);
$updatedGrant->getObjectProperties();
$subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value());
$subuser->getObjectProperties();
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $updatedGrant->asArray(),
]);
}
private function resendInviteForScopedUser(int $subuserId, int $customerNumber): void
{
global $response;
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
$response->error('Subuser not found', 404);
}
$subuser->getObjectProperties();
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
if ($grant === null) {
$response->error('Subuser grant not found for selected user', 404);
}
if (!$subuser->requiresSetup()) {
$response->error('Driver account already accepted the invitation.', 409);
}
$invite = $this->issueSetupInvite($subuser);
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $grant->asArray(),
'invite' => $invite,
]);
}
private function handleInviteSubuserForCustomer(int $customerNumber): void
@@ -617,6 +1016,9 @@ class subusersRoute
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
if (self::isParametersSet(['customer_number']) && (int)self::getParameter('customer_number') !== $customerNumber) {
$response->error('Customer number does not match selected user', 400);
}
self::requireParameters(['name', 'phone_country_code', 'phone']);
@@ -624,6 +1026,7 @@ class subusersRoute
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$templateAccess = $this->parseAccessTemplatePayload();
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -632,6 +1035,10 @@ class subusersRoute
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
@@ -813,9 +1220,14 @@ class subusersRoute
self::requireType($note, self::type_string());
self::requireMaxLength('note', 65535);
}
$templateAccess = $this->parseAccessTemplatePayload();
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
$response->success(['grant' => $grant->asArray()]);
@@ -853,6 +1265,12 @@ class subusersRoute
}
}
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$grant->enabled->set($templateAccess['enabled']);
$grant->permissions->set($templateAccess['permissions']);
}
// Update fields provided in the request
if (self::isParametersSet(['enabled'])) {
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -923,6 +1341,25 @@ class subusersRoute
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
]);
$this->get('/subusers/permission-templates', function () {
global $response;
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|| self::hasPermission('list_subusers')
|| self::hasPermission('add_subusers')
|| self::hasPermission('edit_subusers');
if (!$canUseGlobalManagement) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
}
$response->success((new subuser_permission_templates_service())->accessModel());
}, [
'list_own_subusers' => 'List chauffeur permission templates for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
'manage_subuser_grants' => 'List chauffeur permission templates for administrative grant management.',
'list_subusers' => 'List chauffeur permission templates for superuser management.',
'add_subusers' => 'List chauffeur permission templates while inviting chauffeurs.',
'edit_subusers' => 'List chauffeur permission templates while editing chauffeur grants.',
]);
$this->post('/subusers', function () {
global /** @var response $response */
$response;
@@ -1150,6 +1587,26 @@ class subusersRoute
'list_subusers' => 'List all chauffeur access grants for superusers.',
]);
$this->get('/superuser/users/{user_id}/subusers', function () {
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->addUserScopedSubuserMeta($targetUser);
$response->success($this->listSuperuserSubusers((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'List chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/superuser/users/{user_id}/subusers/summary', function () {
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'Summarize chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/subusers', function () {
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
@@ -1264,6 +1721,14 @@ class subusersRoute
'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/invite', function () {
$this->requirePermission('add_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->handleInviteSubuserForCustomer((int)$targetUser['customer_number']);
}, [
'add_subusers' => 'Invite or link chauffeurs for a selected superuser customer account.',
]);
$this->post('/subusers/invite', function () {
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
$this->handleInviteSubuserForCustomer($customerNumber);
@@ -1306,6 +1771,28 @@ class subusersRoute
'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend', function () {
$this->requirePermission('edit_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->resendInviteForScopedUser(
$this->routePositiveInt('subuser_id'),
(int)$targetUser['customer_number']
);
}, [
'edit_subusers' => 'Resend chauffeur invites for a selected superuser customer account.',
]);
$this->patch('/superuser/users/{user_id}/subusers/grants/{grant_id}', function () {
$this->requirePermission('manage_subuser_grants');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->patchScopedSubuserGrant(
$this->routePositiveInt('grant_id'),
(int)$targetUser['customer_number']
);
}, [
'manage_subuser_grants' => 'Edit chauffeur grants for a selected superuser customer account.',
]);
$this->post('/subusers/invite/resend', function () {
global $response;
@@ -3,11 +3,14 @@
namespace routes;
use classes\authentication;
use classes\department_customer_pricing_service;
use classes\limited_backoffice_exception;
use objects\branding_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
use objects\products_o;
use objects\users_o;
use traits\route_t;
class superuserDepartmentRoute
@@ -38,9 +41,9 @@ class superuserDepartmentRoute
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT', 'Successfully fetched department');
// Return the department
$response->success(
(new departments_o())->getDepartmentById((int)$this->fromRequest('department_id'))
);
$department = (new departments_o())->getDepartmentById((int)$this->fromRequest('department_id'));
$department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
$response->success($department);
} else {
// Log the incident
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT', 'No user found, or invalid session');
@@ -213,6 +216,57 @@ class superuserDepartmentRoute
'superuser_fetch_department_prices' => 'Fetch department prices'
]);
$this->get('/superuser/department/customer-pricing', function () {
global $response;
$this->requirePermission('superuser_fetch_department_customer_pricing');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$departmentId = $this->positiveIntFromRequest('department_id');
$userId = $this->customerUserIdFromRequest();
try {
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'Successfully fetched department customer pricing');
$response->success((new department_customer_pricing_service())->getPricing($departmentId, $userId));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
}, [
'superuser_fetch_department_customer_pricing' => 'Fetch department customer pricing'
]);
$this->put('/superuser/department/customer-pricing', function () {
global $response;
$this->requirePermission('superuser_set_department_customer_pricing');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload)) {
$response->error('Invalid request body', 400);
}
$departmentId = $this->positiveIntFromPayload($payload, 'department_id');
$userId = $this->customerUserIdFromPayload($payload);
try {
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'Successfully set department customer pricing');
$response->success((new department_customer_pricing_service())->updatePricing($departmentId, $userId, $payload));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
}, [
'superuser_set_department_customer_pricing' => 'Set department customer pricing'
]);
$this->get('/superuser/department/variables', function () {
// Require the user to be logged in
global $response;
@@ -295,4 +349,78 @@ class superuserDepartmentRoute
]);
}
private function positiveIntFromRequest(string $name): int
{
global $response;
$value = $this->fromRequest($name);
if (!is_string($value) && !is_int($value)) {
$response->error($name . ' is required', 400);
}
return $this->positiveIntValue($value, $name);
}
private function customerUserIdFromRequest(): int
{
if ($this->fromRequest('user_id') !== null) {
return $this->positiveIntFromRequest('user_id');
}
return $this->userIdFromCustomerNumber($this->positiveIntFromRequest('customer_number'));
}
/**
* @param array<string, mixed> $payload
*/
private function positiveIntFromPayload(array $payload, string $name): int
{
global $response;
if (!array_key_exists($name, $payload)) {
$response->error($name . ' is required', 400);
}
return $this->positiveIntValue($payload[$name], $name);
}
/**
* @param array<string, mixed> $payload
*/
private function customerUserIdFromPayload(array $payload): int
{
if (array_key_exists('user_id', $payload)) {
return $this->positiveIntFromPayload($payload, 'user_id');
}
return $this->userIdFromCustomerNumber($this->positiveIntFromPayload($payload, 'customer_number'));
}
private function positiveIntValue(mixed $value, string $name): int
{
global $response;
if (is_int($value)) {
$parsed = $value;
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$parsed = (int)trim($value);
} else {
$response->error($name . ' must be a positive integer', 400);
}
if ($parsed <= 0) {
$response->error($name . ' must be a positive integer', 400);
}
return $parsed;
}
private function userIdFromCustomerNumber(int $customerNumber): int
{
global $response;
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
return (int)$customer->id;
}
}
@@ -33,10 +33,9 @@ class systemSearchRoute
$response->success([
'message' => 'System search cache cleared',
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_clear' => 'Clear system-wide search query and intent caches',
'superuser_search_system_cache_clear' => 'Clear system-wide search query caches',
]);
$this->post('/superuser/search/system/cache/rebuild', function () {
@@ -49,15 +48,12 @@ class systemSearchRoute
$types = $this->parseTypeList($params['types'] ?? []);
$request = system_search_cache::enqueueRebuild($scope, $types);
// Rebuild endpoint also clears parser namespace immediately.
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$response->success([
'message' => 'System search cache rebuild queued',
'request' => $request,
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild',
@@ -86,9 +82,7 @@ class systemSearchRoute
$includeTypes = $this->parseTypeList($params['include_types'] ?? []);
$excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []);
$includeAssociations = $this->toBool($params['include_associations'] ?? true, true);
$debugIntent = $this->toBool($params['debug_intent'] ?? false, false);
$limit = $this->clampInt((int)($params['limit'] ?? 50), 1, 200, 50);
$offset = max(0, (int)($params['offset'] ?? 0));
$maxResults = $this->clampMaxResults((int)($params['max_results'] ?? 50));
[$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes();
if (empty($allowedTypes)) {
@@ -118,9 +112,7 @@ class systemSearchRoute
'permissions_catalog_own' => $permissionsCatalogOwn,
'module_config_visibility' => $this->buildModuleConfigVisibility(),
'include_associations' => $includeAssociations,
'debug_intent' => $debugIntent,
'limit' => $limit,
'offset' => $offset,
'max_results' => $maxResults,
]);
$response->success($result);
@@ -596,6 +588,11 @@ class systemSearchRoute
return $value;
}
private function clampMaxResults(int $value): int
{
return $this->clampInt($value, 1, 50, 50);
}
private function allEntityTypes(): array
{
return array_keys($this->entityPermissionMap());
+3 -1
View File
@@ -33,9 +33,11 @@ class userRoute
// Return an error
$response->error('User not found', 404);
}
$targetUserData = $targetUser->includeIncludes(['all'])->asArray();
$targetUserData['limited_backoffice_managed'] = (new users_o())->isLimitedBackofficeManagedUser((int)$targetUser->id);
// Return the list of users
$response->success(
$targetUser->includeIncludes(['all'])->asArray()
$targetUserData
);
} else {
// Log the incident
+18 -3
View File
@@ -242,7 +242,7 @@ class usersRoute
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if (!$enabled || $filters === null || $filters === '') {
if ($filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
@@ -257,16 +257,31 @@ class usersRoute
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) {
// When include mode is on but the filter is not a customer_number:0 query,
// pass the original filter through as forced filters so they are not discarded.
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
// back to reading the filters from the request, which is equivalent.
return [
'enabled' => false,
'filters' => $filters,
'filters' => $enabled ? $filters : null,
'additional_where' => null,
];
}
unset($filterArray['customer_number']);
// $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
if (!$enabled) {
// Exclude active limited backoffice employees when the include flag is not set.
return [
'enabled' => false,
'filters' => null,
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
];
}
unset($filterArray['customer_number']);
return [
'enabled' => true,
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
+392
View File
@@ -21,6 +21,311 @@ class vehiclesRoute
{
use route_t;
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function resolveSuperuserVehicleTargetUser(int $userId): array
{
global $response;
$targetUser = (new users_o())->select($userId);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
$targetUser->getObjectProperties();
$customerNumber = (int)$targetUser->customer_number->value();
if ($customerNumber <= 0) {
$response->error('Selected user does not have a customer number', 400);
}
return [
'user_id' => (int)$targetUser->id,
'customer_number' => $customerNumber,
'customer_name' => (string)$targetUser->getCustomerName($customerNumber),
];
}
private function addUserScopedVehicleMeta(array $targetUser): void
{
global $response;
$response->add_meta('user_context', $targetUser);
$response->add_meta('vehicles_summary', $this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
}
private function buildVehiclePayload(array $vehicle): array
{
return [...(new customer_vehicles_o())->select((int)$vehicle['id'])->asArray()];
}
private function listVehiclesForCustomer(int $customerNumber): array
{
$vehicles = new customer_vehicles_o();
return $vehicles->listObjectsWithPaginationIfSet(
fn ($vehicle) => $this->buildVehiclePayload($vehicle),
$vehicles->forceRestrictFilters([
'customer_id' => [$customerNumber],
])
);
}
private function buildVehicleSummaryForCustomer(int $customerNumber): array
{
$vehicles = new customer_vehicles_o();
$filters = [
'customer_id' => $customerNumber,
];
if ($vehicles->columnsExist(['deleted_at'])) {
$filters['deleted_at'] = null;
}
$rows = $vehicles->getFieldsWhere($filters, [
'id',
'wash_subscription',
]);
$summary = [
'total' => 0,
'wash_subscription' => 0,
'self_service' => 0,
];
foreach ($rows as $row) {
$summary['total']++;
if ((int)($row['wash_subscription'] ?? 0) === 1) {
$summary['wash_subscription']++;
}
try {
$vehicle = (new customer_vehicles_o())->select((int)$row['id']);
if ($vehicle->exists() && $vehicle->hasXLVask()) {
$summary['self_service']++;
}
} catch (\Throwable) {
// XLVask availability should not prevent the customer vehicle summary from loading.
}
}
return $summary;
}
private function requireScopedVehicle(int $vehicleId, int $customerNumber): customer_vehicles_o
{
global $response;
$vehicle = (new customer_vehicles_o())->select($vehicleId);
if (!$vehicle->exists()) {
$response->error('Vehicle not found', 404);
}
$vehicle->getObjectProperties();
if ((int)$vehicle->customer_id->value() !== $customerNumber) {
$response->error('Vehicle does not belong to selected user', 404);
}
return $vehicle;
}
private function validateOptionalCustomerIdMatches(int $customerNumber): void
{
global $response;
if (!self::isParametersSet(['customer_id'])) {
return;
}
$requestedCustomerNumber = (int)self::getParameter('customer_id');
self::requireType($requestedCustomerNumber, self::type_int());
if ($requestedCustomerNumber !== $customerNumber) {
$response->error('Customer number does not match selected user', 400);
}
}
private function createVehicleForCustomer(int $customerNumber): array
{
global $response;
self::requireParameters([
'type',
'reg',
]);
$this->validateOptionalCustomerIdMatches($customerNumber);
$reference = null;
if (self::isParametersSet(['reference']) && !empty(self::getParameter('reference'))) {
$reference = (string)self::getParameter('reference');
self::requireType($reference, self::type_string());
self::requireMinLength('reference', 1);
self::requireMaxLength('reference', 255);
}
self::requireType(self::getParameter('reg'), self::type_string());
self::requireType(self::getParameter('type'), self::type_int());
$subscription = false;
if (self::isParametersSet(['wash_subscription'])) {
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
$subscription = (bool)self::getParameter('wash_subscription');
}
$reg = trim((string)self::getParameter('reg'));
self::requireMinLength('reg', 2);
self::requireMaxLength('reg', 12);
$type = (int)self::getParameter('type');
$vehicle = new customer_vehicles_o();
$vehicle->add($customerNumber, $type, $reg, $subscription, $reference);
try {
(new economic_v2_versioning_service())->recordVehicleSubscriptionVersion(
[
'vehicle_id' => (int)$vehicle->id,
'customer_number' => $customerNumber,
'reg' => $reg,
'vehicle_type' => $type,
'wash_subscription' => $subscription,
],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'POST',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
return $vehicle->asArray();
}
private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array
{
global $response;
$this->validateOptionalCustomerIdMatches($customerNumber);
$beforeState = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
if (self::isParametersSet(['type'])) {
$type = (int)self::getParameter('type');
self::requireType($type, self::type_int());
self::requireMinValue($type, 0);
if ($type === 0) {
$vehicle->type->set(0);
$vehicle->wash_subscription->set(0);
} else {
$product = new products_o();
$product->select($type);
if (!$product->exists() || !$product->subscription_allowed->value()) {
$response->error('Invalid type', 400);
}
$vehicle->type->set($type);
}
}
if (self::isParametersSet(['reg'])) {
$reg = (string)self::getParameter('reg');
self::requireType($reg, self::type_string());
self::requireMinLength('reg', 2);
self::requireMaxLength('reg', 12);
$vehicle->reg->set(preg_replace('/\s+/', '', $reg));
}
if (self::isParametersSet(['wash_subscription'])) {
$subscription = (bool)self::getParameter('wash_subscription');
self::requireType($subscription, self::type_bool());
if ((int)$vehicle->type->value() === 0 && $subscription) {
$response->error('Unable to set subscription, type is not set', 400);
}
$vehicle->wash_subscription->set($subscription ? 1 : 0);
}
if (self::isParametersSet(['reference'])) {
if (empty(self::getParameter('reference'))) {
$vehicle->reference->nullify();
} else {
$reference = (string)self::getParameter('reference');
self::requireType($reference, self::type_string());
self::requireMinLength('reference', 1);
self::requireMaxLength('reference', 255);
$vehicle->reference->set($reference);
}
}
$vehicle->objectChanged();
$afterState = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
$versionRelevantChange = (
(string)$beforeState['reg'] !== (string)$afterState['reg'] ||
(int)$beforeState['vehicle_type'] !== (int)$afterState['vehicle_type'] ||
(bool)$beforeState['wash_subscription'] !== (bool)$afterState['wash_subscription']
);
if ($versionRelevantChange) {
try {
$versioning = new economic_v2_versioning_service();
$effectiveAt = date('Y-m-d H:i:s');
if ((string)$beforeState['reg'] !== (string)$afterState['reg']) {
$versioning->closeActiveVehicleSubscriptionVersion(
(int)$beforeState['customer_number'],
(string)$beforeState['reg'],
$effectiveAt,
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
'reason' => 'identity_change',
]
);
}
$versioning->recordVehicleSubscriptionVersion(
$afterState,
$effectiveAt,
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
}
return $vehicle->asArray();
}
public function run(): void
{
$this->get('/vehicles', function () {
@@ -119,6 +424,93 @@ class vehiclesRoute
]
);
$this->get('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('list_vehicles_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$this->addUserScopedVehicleMeta($targetUser);
$response->success($this->listVehiclesForCustomer((int)$targetUser['customer_number']));
}, [
'list_vehicles_other' => 'List vehicles for a selected superuser customer account.',
]);
$this->get('/superuser/users/{user_id}/vehicles/summary', function () {
global $response;
$this->requirePermission('list_vehicles_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
}, [
'list_vehicles_other' => 'Summarize vehicles for a selected superuser customer account.',
]);
$this->post('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('add_vehicle_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->createVehicleForCustomer((int)$targetUser['customer_number']));
}, [
'add_vehicle_other' => 'Add a vehicle for a selected superuser customer account.',
]);
$this->put('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('edit_vehicle_other');
self::requireParameters(['id']);
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$vehicleId = (int)self::getParameter('id');
self::requireType($vehicleId, self::type_int());
self::requireMinValue($vehicleId, 1);
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
$response->add_meta('user_context', $targetUser);
$response->success($this->updateScopedVehicle($vehicle, (int)$targetUser['customer_number']));
}, [
'edit_vehicle_other' => 'Edit a vehicle for a selected superuser customer account.',
]);
$this->delete('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('delete_vehicle_other');
self::requireParameters(['id']);
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$vehicleId = (int)self::getParameter('id');
self::requireType($vehicleId, self::type_int());
self::requireMinValue($vehicleId, 1);
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
$beforeState = [
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
];
$vehicle->delete();
try {
(new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion(
(int)$beforeState['customer_number'],
(string)$beforeState['reg'],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'DELETE',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
$response->add_meta('user_context', $targetUser);
$response->success([
'success' => true,
'message' => 'Vehicle deleted successfully',
]);
}, [
'delete_vehicle_other' => 'Delete a vehicle for a selected superuser customer account.',
]);
$this->post('/vehicles', function () {
global $response;
$auth = new authentication();
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
usesApiSuite();
function wash_certificate_download_legacy_booking(int $customerNumber, int $departmentId, array $attributes = []): array
{
return api_fixtures()->createLegacyBooking(array_merge([
'customer_number' => $customerNumber,
'department' => $departmentId,
'washCertificateStatus' => 'pending',
'status' => 'pending',
], $attributes));
}
it('lets customer accounts reach their own wash certificate download without the download permission', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Customer Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$session['user']['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Wash certificate has not been issued yet');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('keeps customer wash certificate downloads scoped to their own bookings', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Wash Certificate Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Other Wash Certificate Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('lets customer accounts reach the legacy wash certificate pdf download gate', function (): void {
api_test_covers('GET /bookings/download_pdf', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Legacy PDF Other Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Legacy PDF Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->get(
'/bookings/download_pdf?id=' . (int)$booking['id'],
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
usesApiSuite();
function bulk_action_order_item_deleted_at(int $orderItemId): ?string
{
$row = api_test_runtime()->queryOne('SELECT deleted_at FROM order_items WHERE id = ' . $orderItemId . ' LIMIT 1');
return $row['deleted_at'] ?? null;
}
function bulk_action_order_invoice_collection_id(int $orderId): int
{
$row = api_test_runtime()->queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
return (int)($row['invoice_collection_id'] ?? 0);
}
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$product = api_fixtures()->createProduct([
'name' => 'Spot Free rinse',
'price' => 80,
]);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 80,
]);
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'locale' => 'da',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['preview_id'] ?? null)->toBeString()
->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft')
->and($preview['summary']['changed_count'] ?? null)->toBe(1)
->and($preview['order_items'][0]['order_item_id'] ?? null)->toBe((int)$orderItem['id'])
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekraeft',
'locale' => 'da',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
expect(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekræft',
'locale' => 'da',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$applied = $applyResponse->data();
expect($applied['preview'] ?? null)->toBeFalse()
->and($applied['result']['changed_count'] ?? null)->toBe(1)
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull();
});
it('previews and applies customer rule cleanup for both spotfree addon products', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup-spotfree-addons');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup-spotfree-addons');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Spotfree Addons Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$spotfreeVanProduct = api_fixtures()->createProduct([
'id' => 23,
'name' => 'Skylning med RO - Varevogn',
'category' => 4,
'price' => 39,
]);
$spotfreeTruckProduct = api_fixtures()->createProduct([
'id' => 24,
'name' => 'Skylning med RO - Lastbil',
'category' => 4,
'price' => 39,
]);
$vanOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeVanProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$truckOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeTruckProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
$previewOrderItemIds = array_map('intval', array_column($preview['order_items'] ?? [], 'order_item_id'));
sort($previewOrderItemIds);
expect($preview['summary']['changed_count'] ?? null)->toBe(2)
->and($previewOrderItemIds)->toBe([(int)$vanOrderItem['id'], (int)$truckOrderItem['id']])
->and(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->toBeNull();
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->not->toBeNull();
});
it('merges selected invoice collections into the explicit target after confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'merge');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'merge');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Merge Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$sourceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$targetOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
]);
$sourceOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['confirmation_phrase'] ?? null)->toBe('Confirm')
->and($preview['target_invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
->and($preview['summary']['orders_to_move'] ?? null)->toBe(1)
->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id']);
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id']);
});
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lets customer booking sessions read their own customer attributes', function (): void {
api_test_covers('GET /customer/attributes', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$attributes = array_map(
static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''),
is_array($response->data()) ? $response->data() : []
);
expect($attributes)->toContain('onlyTankCleaning');
expect($response->body)->not->toContain('list_customer_attributes');
});
it('keeps customer attribute reads scoped to the authenticated customer', function (): void {
api_test_covers('GET /customer/attributes', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Attribute Customer']);
api_fixtures()->addCustomerAttribute((int)$otherCustomer['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$otherCustomer['customer_number'],
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['list_customer_attributes']);
});
it('still lets attribute managers read another customer attributes', function (): void {
api_test_covers('GET /customer/attributes', 'permissions');
$session = api_fixtures()->createUserSession(['list_customer_attributes']);
$customer = api_fixtures()->createUser(['display_name' => 'Managed Attribute Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$attributes = array_map(
static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''),
is_array($response->data()) ? $response->data() : []
);
expect($attributes)->toContain('onlyTankCleaning');
});
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
use classes\limited_backoffice_service;
usesApiSuite();
function department_customer_pricing_price_insert(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
function department_customer_pricing_setup(array $departmentAttributes = []): array
{
$department = api_fixtures()->createDepartment([
'name' => 'Scoped Customer Pricing Department',
'custom_pricing_only' => 1,
...$departmentAttributes,
]);
$category = api_fixtures()->createCategory(['name' => 'Scoped Customer Pricing Category']);
$product = api_fixtures()->createProduct([
'name' => 'Scoped Customer Pricing Product',
'category' => $category['id'],
'price' => 1000,
'apply_category_discount' => 1,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
department_customer_pricing_price_insert((int)$department['id'], (int)$product['id'], 1000);
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Customer Pricing Customer']);
return [
'department' => $department,
'category' => $category,
'product' => $product,
'customer' => $customer,
];
}
it('rejects department customer pricing when custom-only pricing is disabled', function (): void {
api_test_covers('GET /superuser/department/customer-pricing', 'validation');
$fixture = department_customer_pricing_setup(['custom_pricing_only' => 0]);
$session = api_fixtures()->createUserSession(['superuser_fetch_department_customer_pricing']);
$response = api_client()->get(
'/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] .
'&user_id=' . (int)$fixture['customer']['id'],
$session['headers']
);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
expect($response->data()['code'] ?? null)->toBe('department_customer_pricing_disabled');
});
it('sets and applies department-specific customer discounts without legacy fallback', function (): void {
api_test_covers('GET /superuser/department/customer-pricing', 'happy');
api_test_covers('PUT /superuser/department/customer-pricing', 'happy');
api_test_covers('GET /products', 'pricing');
$fixture = department_customer_pricing_setup();
api_fixtures()->createPriceOverride([
'user_id' => $fixture['customer']['id'],
'is_category' => 0,
'product_or_category_id' => (string)$fixture['product']['id'],
'percentage' => 80,
]);
$session = api_fixtures()->createUserSession([
'superuser_fetch_department_customer_pricing',
'superuser_set_department_customer_pricing',
'list_products',
'department_access_' . (int)$fixture['department']['id'],
]);
$updated = api_client()->put('/superuser/department/customer-pricing', [
'department_id' => $fixture['department']['id'],
'user_id' => $fixture['customer']['id'],
'overrides' => [
[
'is_category' => false,
'product_or_category_id' => $fixture['product']['id'],
'discount' => 25,
'fixed_price' => null,
],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(25);
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(750);
$byCustomerNumber = api_client()->get(
'/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] .
'&customer_number=' . (int)$fixture['customer']['customer_number'],
$session['headers']
);
$byCustomerNumber
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($byCustomerNumber->data()['customer']['id'] ?? null)->toBe((int)$fixture['customer']['id']);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . (int)$fixture['product']['id'] .
'&department_id=' . (int)$fixture['department']['id'] .
'&customer_id=' . (int)$fixture['customer']['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($productResponse->data()['price'] ?? 0))->toBe(750);
});
it('limits department customer pricing to assigned limited-backoffice departments', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
$fixture = department_customer_pricing_setup();
$otherFixture = department_customer_pricing_setup(['name' => 'Denied Scoped Customer Pricing Department']);
$session = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
]);
api_client()
->get(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] .
'/customer-pricing?user_id=' . (int)$fixture['customer']['id'],
api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
'department_access_' . (int)$fixture['department']['id'],
])['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING]);
api_client()
->get(
'/limited-backoffice/departments/' . (int)$otherFixture['department']['id'] .
'/customer-pricing?user_id=' . (int)$otherFixture['customer']['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherFixture['department']['id']]);
api_client()
->put(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing',
[
'user_id' => $fixture['customer']['id'],
'overrides' => [],
],
api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
])['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING]);
$updated = api_client()->put(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing',
[
'user_id' => $fixture['customer']['id'],
'overrides' => [
[
'is_category' => true,
'product_or_category_id' => (string)$fixture['category']['id'],
'discount' => 30,
],
],
],
$session['headers']
);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(30);
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(700);
});
@@ -163,6 +163,39 @@ it('rejects department listing when the permission is missing', function (): voi
->assertMissingPermissions(['list_departments']);
});
it('returns superuser department custom pricing state as a boolean', function (): void {
$session = api_fixtures()->createUserSession(['superuser_fetch_department']);
$fallbackDepartment = api_fixtures()->createDepartment([
'name' => 'Fallback Pricing Department',
'custom_pricing_only' => 0,
]);
$customOnlyDepartment = api_fixtures()->createDepartment([
'name' => 'Custom Only Pricing Department',
'custom_pricing_only' => 1,
]);
$fallbackResponse = api_client()->get(
'/superuser/department?department_id=' . $fallbackDepartment['id'],
$session['headers']
);
$customOnlyResponse = api_client()->get(
'/superuser/department?department_id=' . $customOnlyDepartment['id'],
$session['headers']
);
$fallbackResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$customOnlyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($fallbackResponse->data()['custom_pricing_only'] ?? null)->toBeFalse();
expect($customOnlyResponse->data()['custom_pricing_only'] ?? null)->toBeTrue();
});
it('creates departments through the real endpoint', function (): void {
api_test_covers('POST /departments', 'happy');
@@ -163,6 +163,22 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
->toHaveKey('session_type', 'gateway-stream')
->toHaveKey('gateway_id', (int)$gateway['id']);
api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
[
'status' => 'connected',
'connection_id' => 'broker-presence-1',
'metadata' => [
'transport' => 'ws',
'refreshed_for' => 'shell-session',
],
],
edge_test_broker_headers()
)
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
['reason' => 'Broker shell validation'],
@@ -11,6 +11,8 @@ function limited_backoffice_manager_session(array $departmentIds, array $extraPe
$permissions = [
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_MANAGE_PRICES,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING,
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
];
foreach ($departmentIds as $departmentId) {
@@ -25,6 +27,7 @@ function limited_backoffice_all_role_permissions(): array
return [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'fetch_order',
'add_order',
@@ -71,8 +74,13 @@ function limited_backoffice_all_role_permissions(): array
'department_timebookings_entries_get',
'department_timebookings_entries_post',
'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
'limited_backoffice_customer_pricing_view',
'limited_backoffice_customer_pricing_manage',
];
}
@@ -198,10 +206,6 @@ it('lists and updates explicit prices only for assigned departments', function (
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
$productDeletedAtColumn = api_test_runtime()->db()->query("SHOW COLUMNS FROM `products` LIKE 'deleted_at'");
expect($productDeletedAtColumn)->not->toBeFalse();
expect((int)$productDeletedAtColumn->num_rows)->toBe(0);
$department = api_fixtures()->createDepartment(['name' => 'Limited Prices Own']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Prices Other']);
$category = api_fixtures()->createCategory(['name' => 'Limited Washes']);
@@ -875,6 +879,8 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([
'open_limited_backoffice',
'manage_department_prices',
'view_customer_pricing',
'manage_customer_pricing',
'manage_employee_access',
]);
expect($roles->body)->not->toContain('department_access_');
@@ -887,6 +893,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
});
foreach ([
'list_orders',
'list_departments',
'fetch_order',
'add_order',
'edit_order',
@@ -897,19 +904,15 @@ it('creates updates lists and deactivates scoped employees without exposing raw
'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',
@@ -932,10 +935,15 @@ it('creates updates lists and deactivates scoped employees without exposing raw
'department_timebookings_entries_get',
'department_timebookings_entries_post',
'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
'limited_backoffice_access',
'limited_backoffice_prices_manage',
'limited_backoffice_customer_pricing_view',
'limited_backoffice_customer_pricing_manage',
'limited_backoffice_employees_manage',
] as $rawPermission) {
expect($rolePayloadStrings)->not->toContain($rawPermission);
@@ -1079,7 +1087,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
});
});
it('caps limited employee permissions to the manager permissions and selected departments', function (): void {
it('caps manager-gated limited employee permissions while keeping baseline role permissions', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'auth');
api_test_covers('GET /limited-backoffice/roles', 'auth');
@@ -1092,9 +1100,11 @@ it('caps limited employee permissions to the manager permissions and selected de
$rolesByKey = array_column($roles->data(), null, 'key');
$operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key');
expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in', 'view_own_permissions']);
expect($operationsLeadGroups['departments'] ?? null)->toBe(['view_departments', 'view_daily_reports']);
expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']);
expect($operationsLeadGroups['notifications'] ?? null)->toBe(['view_notifications']);
expect($operationsLeadGroups['reports'] ?? null)->toBe(['view_order_statistics', 'view_booking_statistics']);
expect($roles->body)->not->toContain('create_orders');
expect($roles->body)->not->toContain('view_order_statistics');
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Capped Lead',
@@ -1126,7 +1136,13 @@ it('caps limited employee permissions to the manager permissions and selected de
->toContain('user')
->toContain('permissions_list_own')
->toContain('employee_public_data')
->toContain('list_departments')
->toContain('list_orders')
->toContain('list_department_daily_reports')
->toContain('list_notifications')
->toContain('list_own_notifications')
->toContain('statistics_orders_new')
->toContain('statistics_bookings_new')
->toContain('department_access_' . (int)$department['id'])
->not->toContain('add_order')
->not->toContain('fetch_order')
@@ -1134,7 +1150,6 @@ it('caps limited employee permissions to the manager permissions and selected de
->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);
});
@@ -1313,6 +1328,7 @@ it('rejects invalid limited backoffice employee QR login link generation', funct
it('includes limited employees in the regular employee list and protects raw user edits', function (): void {
api_test_covers('GET /users', 'limited backoffice employee list');
api_test_covers('PUT /users', 'limited backoffice guard');
api_test_covers('GET /superuser/user', 'limited backoffice managed flag');
$department = api_fixtures()->createDepartment(['name' => 'Limited Regular List']);
$managerSession = limited_backoffice_manager_session([(int)$department['id']], [
@@ -1342,6 +1358,7 @@ it('includes limited employees in the regular employee list and protects raw use
expect($created->data()['customer_number'] ?? null)->toBe(0);
$adminSession = api_fixtures()->createUserSession([
'get_user',
'list_users',
'edit_user',
]);
@@ -1361,6 +1378,13 @@ it('includes limited employees in the regular employee list and protects raw use
expect($usersById[$employeeId]['limited_backoffice_managed'] ?? null)->toBeTrue();
expect($usersById[(int)$regularEmployee['id']]['limited_backoffice_managed'] ?? null)->toBeFalse();
$superuserDetail = api_client()->get('/superuser/user?user_id=' . $employeeId, $adminSession['headers']);
$superuserDetail
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($superuserDetail->data()['limited_backoffice_managed'] ?? null)->toBeTrue();
$userRow = api_test_runtime()->queryOne(
'SELECT `customer_number`, `group_id` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1'
);
@@ -1610,3 +1634,561 @@ it('rejects invalid limited backoffice employee contact details', function (): v
->assertSuccess(false)
->assertMessage('Phone number must be 4-15 digits.');
});
it('includes list_departments in all active limited backoffice role presets', function (): void {
foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) {
$permissions = limited_backoffice_role_preset_permissions($roleKey);
expect($permissions)->toContain('list_departments');
}
});
it('includes list_department_daily_reports (dagsopgørelse) in all active limited backoffice role presets', function (): void {
foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) {
$permissions = limited_backoffice_role_preset_permissions($roleKey);
expect($permissions)->toContain('list_department_daily_reports');
}
});
it('includes list_notifications and list_own_notifications (Notifikationer) in all active limited backoffice role presets', function (): void {
foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) {
$permissions = limited_backoffice_role_preset_permissions($roleKey);
expect($permissions)
->toContain('list_notifications')
->toContain('list_own_notifications');
}
});
it('includes statistics_orders_new and statistics_bookings_new (Overblik) in all active limited backoffice role presets', function (): void {
foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) {
$permissions = limited_backoffice_role_preset_permissions($roleKey);
expect($permissions)
->toContain('statistics_orders_new')
->toContain('statistics_bookings_new');
}
});
it('includes customer pricing permissions in the department admin limited backoffice role preset', function (): void {
$permissions = limited_backoffice_role_preset_permissions('department_admin');
expect($permissions)
->toContain(limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING)
->toContain(limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING);
});
it('assigns list_departments to managed employees and enforces list_departments permission on GET /departments', function (): void {
api_test_covers('GET /departments', 'limited backoffice employee');
$department = api_fixtures()->createDepartment(['name' => 'LB Dept List Test']);
$manager = limited_backoffice_manager_session([(int)$department['id']]);
$result = api_client()->post('/limited-backoffice/employees', [
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
'display_name' => 'Dept List Test Employee',
'email' => 'dept-list-test@example.test',
'password' => 'Secret123!',
], $manager['headers']);
$result->assertStatus(200)->assertEnvelope()->assertSuccess();
$employeeId = (int)($result->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$stmt = api_test_runtime()->db()->prepare(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1'
);
$stmt->bind_param('i', $employeeId);
$stmt->execute();
$employeeRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
$managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0);
expect($managedGroupId)->toBeGreaterThan(0);
$permStmt = api_test_runtime()->db()->prepare(
'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1'
);
$listDepts = 'list_departments';
$permStmt->bind_param('is', $managedGroupId, $listDepts);
$permStmt->execute();
$permRow = $permStmt->get_result()->fetch_assoc();
$permStmt->close();
expect($permRow)->not->toBeNull('Managed employee must have list_departments permission');
});
it('assigns list_department_daily_reports to managed employees', function (): void {
$department = api_fixtures()->createDepartment(['name' => 'LB Daily Report Perm Test']);
$manager = limited_backoffice_manager_session([(int)$department['id']]);
$result = api_client()->post('/limited-backoffice/employees', [
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
'display_name' => 'Daily Report Perm Employee',
'email' => 'daily-report-perm@example.test',
'password' => 'Secret123!',
], $manager['headers']);
$result->assertStatus(200)->assertEnvelope()->assertSuccess();
$employeeId = (int)($result->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$stmt = api_test_runtime()->db()->prepare(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1'
);
$stmt->bind_param('i', $employeeId);
$stmt->execute();
$employeeRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
$managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0);
expect($managedGroupId)->toBeGreaterThan(0);
$permStmt = api_test_runtime()->db()->prepare(
'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1'
);
$dailyReports = 'list_department_daily_reports';
$permStmt->bind_param('is', $managedGroupId, $dailyReports);
$permStmt->execute();
$permRow = $permStmt->get_result()->fetch_assoc();
$permStmt->close();
expect($permRow)->not->toBeNull('Managed employee must have list_department_daily_reports permission');
});
it('assigns list_notifications and list_own_notifications to managed employees', function (): void {
$department = api_fixtures()->createDepartment(['name' => 'LB Notifications Perm Test']);
$manager = limited_backoffice_manager_session([(int)$department['id']]);
$result = api_client()->post('/limited-backoffice/employees', [
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
'display_name' => 'Notifications Perm Employee',
'email' => 'notifications-perm@example.test',
'password' => 'Secret123!',
], $manager['headers']);
$result->assertStatus(200)->assertEnvelope()->assertSuccess();
$employeeId = (int)($result->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$stmt = api_test_runtime()->db()->prepare(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1'
);
$stmt->bind_param('i', $employeeId);
$stmt->execute();
$employeeRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
$managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0);
expect($managedGroupId)->toBeGreaterThan(0);
foreach (['list_notifications', 'list_own_notifications'] as $perm) {
$stmt = api_test_runtime()->db()->prepare(
'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1'
);
$stmt->bind_param('is', $managedGroupId, $perm);
$stmt->execute();
$permRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
expect($permRow)->not->toBeNull("Managed employee must have $perm permission");
}
});
it('assigns statistics permissions (Overblik) to all active limited backoffice role presets including cashier and booking_coordinator', function (): void {
$department = api_fixtures()->createDepartment(['name' => 'LB Stats Perm Test']);
$manager = limited_backoffice_manager_session([(int)$department['id']]);
foreach (['cashier', 'booking_coordinator'] as $roleKey) {
$result = api_client()->post('/limited-backoffice/employees', [
'role_key' => $roleKey,
'department_ids' => [(int)$department['id']],
'display_name' => 'Stats Perm ' . $roleKey,
'email' => 'stats-perm-' . $roleKey . '@example.test',
'password' => 'Secret123!',
], $manager['headers']);
$result->assertStatus(200)->assertEnvelope()->assertSuccess();
$employeeId = (int)($result->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$stmtEmp = api_test_runtime()->db()->prepare(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1'
);
$stmtEmp->bind_param('i', $employeeId);
$stmtEmp->execute();
$employeeRow = $stmtEmp->get_result()->fetch_assoc();
$stmtEmp->close();
$managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0);
expect($managedGroupId)->toBeGreaterThan(0);
foreach (['statistics_orders_new', 'statistics_bookings_new'] as $perm) {
$stmt = api_test_runtime()->db()->prepare(
'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1'
);
$stmt->bind_param('is', $managedGroupId, $perm);
$stmt->execute();
$permRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
expect($permRow)->not->toBeNull("$roleKey managed employee must have $perm permission");
}
}
});
it('enforces department access when creating an order via POST /orders', function (): void {
api_test_covers('POST /orders', 'department access');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Create Allowed Dept']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Create Denied Dept']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
$session = api_fixtures()->createUserSession([
'add_order',
'department_access_' . (int)$allowedDepartment['id'],
]);
// Should succeed for accessible department
$created = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
'reference' => 'DEPT-ACCESS-TEST',
'notes' => '',
'reg_1' => 'ABCD111',
], $session['headers']);
$created->assertStatus(200)->assertEnvelope()->assertSuccess();
// Should fail for inaccessible department
api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
'reference' => 'DEPT-ACCESS-DENIED',
'notes' => '',
'reg_1' => 'ABCD222',
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
});
it('enforces department access on the existing order when editing via PUT /order', function (): void {
api_test_covers('PUT /order', 'department access');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Edit Allowed Dept']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Edit Denied Dept']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Edit Customer']);
$orderInAllowed = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$orderInDenied = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
]);
$session = api_fixtures()->createUserSession([
'edit_order',
'department_access_' . (int)$allowedDepartment['id'],
]);
// Should succeed editing order in accessible department
api_client()->put('/order', [
'id' => (int)$orderInAllowed['id'],
'notes' => 'updated',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
// Should fail editing order in inaccessible department
api_client()->put('/order', [
'id' => (int)$orderInDenied['id'],
'notes' => 'should be denied',
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
});
it('enforces department access when moving an order to a new department via PUT /order', function (): void {
api_test_covers('PUT /order', 'department access move');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Move Allowed Dept']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Move Denied Dept']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Move Customer']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$sessionBothDepts = api_fixtures()->createUserSession([
'edit_order',
'department_access_' . (int)$allowedDepartment['id'],
'department_access_' . (int)$deniedDepartment['id'],
]);
$sessionOnlyAllowed = api_fixtures()->createUserSession([
'edit_order',
'department_access_' . (int)$allowedDepartment['id'],
]);
// Should fail when moving to inaccessible department (user only has access to allowedDepartment)
api_client()->put('/order', [
'id' => (int)$order['id'],
'department_id' => (int)$deniedDepartment['id'],
], $sessionOnlyAllowed['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
// Should succeed when user has access to both departments
api_client()->put('/order', [
'id' => (int)$order['id'],
'department_id' => (int)$deniedDepartment['id'],
], $sessionBothDepts['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('enforces department access when marking an order as completed via POST /orders/mark_as_completed', function (): void {
api_test_covers('POST /orders/mark_as_completed', 'department access');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Complete Allowed Dept']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Complete Denied Dept']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Complete Customer']);
$orderAllowed = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$orderDenied = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
]);
$session = api_fixtures()->createUserSession([
'mark_order_as_completed',
'department_access_' . (int)$allowedDepartment['id'],
]);
// Should fail for inaccessible department
api_client()->post('/orders/mark_as_completed', [
'id' => (int)$orderDenied['id'],
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
// Should succeed for accessible department
api_client()->post('/orders/mark_as_completed', [
'id' => (int)$orderAllowed['id'],
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('enforces department access when deleting order attachments via DELETE /orders/attachments', function (): void {
api_test_covers('DELETE /orders/attachments', 'department access');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Del Attach Allowed']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Del Attach Denied']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Del Attach Customer']);
$orderAllowed = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$orderDenied = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
]);
$attachmentAllowed = api_fixtures()->createOrderAttachment(['order_id' => (int)$orderAllowed['id']]);
$attachmentDenied = api_fixtures()->createOrderAttachment(['order_id' => (int)$orderDenied['id']]);
$session = api_fixtures()->createUserSession([
'delete_order_attachments',
'department_access_' . (int)$allowedDepartment['id'],
]);
// Should fail for inaccessible department
api_client()->delete('/orders/attachments', [
'order_id' => (int)$orderDenied['id'],
'attachment_id' => (int)$attachmentDenied['id'],
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
// Should succeed for accessible department
api_client()->delete('/orders/attachments', [
'order_id' => (int)$orderAllowed['id'],
'attachment_id' => (int)$attachmentAllowed['id'],
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('enforces department access when adding order items via POST /order/items', function (): void {
api_test_covers('POST /order/items', 'department access');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Add Allowed']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Add Denied']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Add Customer']);
$category = api_fixtures()->createCategory(['name' => 'Order Item Dept Access Category']);
$product = api_fixtures()->createProduct(['name' => 'Order Item Dept Access Product', 'category' => $category['id']]);
$orderAllowed = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$orderDenied = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
]);
$session = api_fixtures()->createUserSession([
'add_order_items',
'department_access_' . (int)$allowedDepartment['id'],
]);
// Should fail for inaccessible department
api_client()->post('/order/items', [
'order_id' => (int)$orderDenied['id'],
'product_id' => (int)$product['id'],
'quantity' => 1,
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
// Should succeed for accessible department
api_client()->post('/order/items', [
'order_id' => (int)$orderAllowed['id'],
'product_id' => (int)$product['id'],
'quantity' => 1,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('enforces department access when deleting order items via DELETE /order/items', function (): void {
api_test_covers('DELETE /order/items', 'department access');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Del Allowed']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Del Denied']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Del Customer']);
$category = api_fixtures()->createCategory(['name' => 'Order Item Del Category']);
$product = api_fixtures()->createProduct(['name' => 'Order Item Del Product', 'category' => $category['id']]);
$orderAllowed = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$orderDenied = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
]);
$session = api_fixtures()->createUserSession([
'delete_order_items',
'department_access_' . (int)$allowedDepartment['id'],
]);
$superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]);
$itemAllowedResponse = api_client()->post('/order/items', [
'order_id' => (int)$orderAllowed['id'],
'product_id' => (int)$product['id'],
'quantity' => 1,
], $superuserSession['headers']);
$itemAllowed = $itemAllowedResponse->data();
$itemDeniedResponse = api_client()->post('/order/items', [
'order_id' => (int)$orderDenied['id'],
'product_id' => (int)$product['id'],
'quantity' => 1,
], $superuserSession['headers']);
$itemDenied = $itemDeniedResponse->data();
// Should fail for inaccessible department
api_client()->delete('/order/items', ['id' => (int)$itemDenied['id']], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
// Should succeed for accessible department
api_client()->delete('/order/items', ['id' => (int)$itemAllowed['id']], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('enforces department access when editing order items via PUT /order/items', function (): void {
api_test_covers('PUT /order/items', 'department access');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Edit Allowed']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Edit Denied']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']);
$category = api_fixtures()->createCategory(['name' => 'Order Item Edit Category']);
$product = api_fixtures()->createProduct(['name' => 'Order Item Edit Product', 'category' => $category['id']]);
$orderAllowed = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$orderDenied = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
]);
$itemAllowed = api_fixtures()->createOrderItem([
'order_id' => (int)$orderAllowed['id'],
'product_id' => (int)$product['id'],
'cashier_id' => 1,
'price' => 100,
]);
$itemDenied = api_fixtures()->createOrderItem([
'order_id' => (int)$orderDenied['id'],
'product_id' => (int)$product['id'],
'cashier_id' => 1,
'price' => 200,
]);
$session = api_fixtures()->createUserSession([
'edit_order_items',
'list_order_items',
'department_access_' . (int)$allowedDepartment['id'],
]);
// Should fail for inaccessible department
api_client()->put('/order/items', [
'id' => (int)$itemDenied['id'],
'price' => 999,
'notes' => '',
'reference' => '',
'quantity' => 1,
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]);
// Should succeed for accessible department
api_client()->put('/order/items', [
'id' => (int)$itemAllowed['id'],
'price' => 150,
'notes' => '',
'reference' => '',
'quantity' => 1,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
@@ -37,6 +37,18 @@ function order_booking_create_department(string $name): array
]);
}
function order_booking_create_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
}
it('lets customers create their own order bookings without booking permissions', function (): void {
api_test_covers('POST /order-bookings', 'auth');
@@ -65,6 +77,50 @@ it('lets customers create their own order bookings without booking permissions',
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('normalizes booking item prices from server-side customer and department pricing', function (): void {
api_test_covers('POST /order-bookings', 'pricing');
$session = api_fixtures()->createUserSession(['user']);
$department = order_booking_create_department('Own Booking Pricing Department');
$product = api_fixtures()->createProduct([
'name' => 'Booking Price Normalized Product',
'price' => 0,
'is_wash' => 0,
'display_in_booking_form' => 1,
]);
order_booking_create_department_price((int)$department['id'], (int)$product['id'], 425);
$payload = order_booking_create_payload($session['user'], $department, $product, 'PRICEFIX1');
$payload['items'][0]['price'] = 0;
$response = api_client()->post('/order-bookings', $payload, $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$responseItems = $response->data()['items'] ?? [];
expect($responseItems)
->toBeArray()
->and((int)($responseItems[0]['price'] ?? 0))->toBe(425);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
$storedItems = json_decode((string)($row['items'] ?? '[]'), true);
expect($storedItems)
->toBeArray()
->and((int)($storedItems[0]['price'] ?? 0))->toBe(425);
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => (int)$department['id'],
'product_id' => (int)$product['id'],
]);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('blocks subusers creating own customer order bookings without the bookings add node', function (): void {
api_test_covers('POST /order-bookings', 'auth');
@@ -150,7 +150,7 @@ it('uses a product fixed price instead of the best discount when adding an order
'cashier_id' => $cashier['id'],
'reference' => 'FIXED-PRICE',
]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products']);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products', 'department_access_' . $department['id']]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
@@ -281,7 +281,7 @@ it('does not allow clearing notes for order items whose product requires notes',
'quantity' => 1,
'notes' => 'Initial note',
]);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client()
->put('/order/items', [
@@ -73,7 +73,7 @@ it('creates orders through the orders endpoint', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Order Create Department']);
$session = api_fixtures()->createUserSession(['add_order']);
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
$response = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
@@ -128,7 +128,7 @@ it('defaults order PO only from a matching active booking', function (): void {
'po' => 'DELETED-BOOKING-PO',
'deleted_at' => date('Y-m-d H:i:s'),
]);
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [
$session = api_fixtures()->createUserSession(['add_order', 'edit_order', 'department_access_' . $department['id']], [
'customer_number' => $customer['customer_number'],
]);
@@ -149,7 +149,7 @@ it('defaults order PO only from a matching active booking', function (): void {
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [
$unauthorizedSession = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']], [
'customer_number' => $otherCustomer['customer_number'],
]);
$unauthorizedResponse = api_client()->post('/orders', [
@@ -233,7 +233,7 @@ it('rejects invalid order creation requests', function (): void {
$customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment();
$session = api_fixtures()->createUserSession(['add_order']);
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
@@ -262,7 +262,7 @@ it('updates orders through the primary and legacy endpoints', function (): void
'notes' => 'Before update',
'reg_1' => 'BEFORE1',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
$session = api_fixtures()->createUserSession(['edit_order', 'department_access_' . $department['id']]);
api_client()->put('/orders', [
'id' => $order['id'],
@@ -4,6 +4,23 @@ declare(strict_types=1);
usesApiSuite();
function products_api_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
it('treats null-like optional product params as omitted for product detail requests', function (): void {
api_test_covers('GET /products', 'optional-params');
@@ -30,20 +47,161 @@ it('treats null-like optional product params as omitted for product detail reque
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');
it('returns final department pricing without requiring list_products permission', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Department Priced Product',
'price' => 500,
]);
$session = api_fixtures()->createUserSession(['list_products']);
products_api_department_price((int)$department['id'], (int)$product['id'], 375);
$session = api_fixtures()->createUserSession(['user']);
api_client()->get(
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id'])
->toHaveKey('price', 375);
expect($response->body)->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions read own single-product final pricing with customer context', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Single Customer Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Single Customer Priced Product',
'price' => 0,
'display_in_booking_form' => 0,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 175);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id']
. '&customer_id=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id'])
->toHaveKey('price', 175);
expect($response->body)->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions list public products with final department pricing', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Customer Booking Products Department']);
$product = api_fixtures()->createProduct([
'name' => 'Customer Booking Visible Wash',
'price' => 700,
'is_wash' => 1,
'display_in_booking_form' => 1,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 650);
$hiddenProduct = api_fixtures()->createProduct([
'name' => 'Customer Booking Hidden Wash',
'price' => 900,
'is_wash' => 1,
'display_in_booking_form' => 0,
]);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?department_id=' . (int)$department['id'] . '&final_price=true',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$productsById = [];
foreach ($response->data() as $returnedProduct) {
$productsById[(int)($returnedProduct['id'] ?? 0)] = $returnedProduct;
}
expect($productsById)
->toHaveKey((int)$product['id'])
->and($productsById[(int)$product['id']]['name'] ?? null)->toBe('Customer Booking Visible Wash')
->and((int)($productsById[(int)$product['id']]['price'] ?? 0))->toBe(650)
->and($productsById[(int)$product['id']]['display_in_booking_form'] ?? null)->toBeTrue();
expect($productsById)->not->toHaveKey((int)$hiddenProduct['id']);
expect($response->body)
->not->toContain('list_products')
->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions with list_products read own final department pricing without department access', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$department = api_fixtures()->createDepartment(['name' => 'Own Customer Booking Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Own Customer Booking Wash',
'price' => 800,
'is_wash' => 1,
'display_in_booking_form' => 1,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 725);
$session = api_fixtures()->createUserSession(['user', 'list_products']);
$response = api_client()->get(
'/products?department_id=' . (int)$department['id']
. '&final_price=true'
. '&customer_id=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$productsById = [];
foreach ($response->data() as $returnedProduct) {
$productsById[(int)($returnedProduct['id'] ?? 0)] = $returnedProduct;
}
expect($productsById)
->toHaveKey((int)$product['id'])
->and((int)($productsById[(int)$product['id']]['price'] ?? 0))->toBe(725);
expect($response->body)
->not->toContain('department_access_' . (int)$department['id']);
});
it('still requires department access when list_products users request another customer final department pricing', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$department = api_fixtures()->createDepartment(['name' => 'Other Customer Pricing Department']);
$otherCustomer = api_fixtures()->createUser();
$session = api_fixtures()->createUserSession(['user', 'list_products']);
api_client()->get(
'/products?department_id=' . (int)$department['id']
. '&final_price=true'
. '&customer_id=' . (int)$otherCustomer['customer_number'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
@@ -51,6 +209,22 @@ it('still requires department access when final product pricing uses a real depa
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
it('prevents customer booking sessions from requesting another customer product pricing', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser();
api_client()->get(
'/products?final_price=true&customer_id=' . (int)$otherCustomer['customer_number'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['list_products']);
});
it('rejects invalid department ids without requesting department access zero', function (): void {
api_test_covers('GET /products', 'validation');
@@ -58,7 +232,7 @@ it('rejects invalid department ids without requesting department access zero', f
'name' => 'Invalid Department Product',
'price' => 600,
]);
$session = api_fixtures()->createUserSession(['list_products']);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lists limited backoffice permission templates for superuser role maintenance', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'happy');
$session = api_fixtures()->createUserSession([
'superuser',
'add_role_permission',
]);
$response = api_client()->get('/roles/limited-backoffice-permission-templates', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$templates = $response->data();
expect(array_column($templates, 'key'))->toBe([
'viewer',
'cashier',
'booking_coordinator',
'operations_lead',
'department_admin',
]);
$templatesByKey = array_column($templates, null, 'key');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_department_daily_reports');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_notifications');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('statistics_orders_new');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_access');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_prices_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_customer_pricing_view');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_customer_pricing_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_employees_manage');
});
it('requires superuser and role permission edit access for limited backoffice permission templates', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'auth');
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['add_role_permission'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser']);
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['superuser'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_role_permission']);
});
@@ -112,6 +112,32 @@ it('requires subuser management access before exposing permission nodes', functi
expect($authorized->data())->toBeArray()->not->toBeEmpty();
});
it('requires subuser management access before exposing simplified permission templates', function (): void {
api_test_covers('GET /subusers/permission-templates', 'auth');
api_test_covers('GET /subusers/permission-templates', 'happy');
$unauthenticated = api_client()->get('/subusers/permission-templates');
$unauthenticated
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false);
$session = api_fixtures()->createUserSession(['list_own_subusers']);
$authorized = api_client()->get('/subusers/permission-templates', $session['headers']);
$authorized
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($authorized->data()['templates'] ?? null)->toBeArray()->not->toBeEmpty();
expect(array_column($authorized->data()['templates'], 'key'))
->toContain('driver')
->toContain('booking_coordinator')
->toContain('fleet_admin')
->toContain('deactivated');
expect($authorized->data()['groups'] ?? null)->toBeArray()->not->toBeEmpty();
});
it('rejects customer subuser listing without own-scope permission', function (): void {
api_test_covers('GET /subusers', 'auth');
@@ -202,6 +228,11 @@ it('lists chauffeur grants across customers for superusers', function (): void {
(int)$secondCustomer['customer_number'],
['BOOKINGS_LIST']
);
$sharedGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$secondCustomer['customer_number'],
['ORDERS_LIST']
);
$response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']);
@@ -213,22 +244,26 @@ it('lists chauffeur grants across customers for superusers', function (): void {
$rows = array_values(array_filter(
is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item)
&& in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true)
&& in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true)
));
expect($rows)->toHaveCount(2);
$byGrantId = [];
$bySubuserId = [];
foreach ($rows as $row) {
$byGrantId[(int)$row['grant_id']] = $row;
$bySubuserId[(int)$row['id']] = $row;
}
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha');
expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']);
expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']);
expect($bySubuserId[(int)$firstSubuser['id']]['grant_count'])->toBe(2);
expect(array_column($bySubuserId[(int)$firstSubuser['id']]['grants'], 'grant_id'))
->toContain($firstGrantId)
->toContain($sharedGrantId);
expect($bySubuserId[(int)$firstSubuser['id']]['customer_numbers'])
->toContain((int)$firstCustomer['customer_number'])
->toContain((int)$secondCustomer['customer_number']);
expect($bySubuserId[(int)$secondSubuser['id']]['grant_count'])->toBe(1);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['grant_id'])->toBe($secondGrantId);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['customer_name'])->toBe('Fleet Customer Beta');
});
it('lets superusers invite chauffeurs for a selected customer', function (): void {
@@ -280,3 +315,275 @@ it('lets superusers invite chauffeurs for a selected customer', function (): voi
}
}
});
it('applies simplified driver access templates when inviting and updating chauffeur grants', function (): void {
api_test_covers('POST /superuser/subusers/invite', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'failure');
$session = api_fixtures()->createUserSession(['add_subusers', 'manage_subuser_grants']);
$customer = api_fixtures()->createUser(['display_name' => 'Template Target Customer']);
$phone = 72000000 + ((int)$customer['customer_number'] % 1000000);
$createdSubuserId = null;
$createdGrantId = null;
$setupToken = null;
try {
$invite = api_client()->post('/superuser/subusers/invite', [
'customer_number' => (int)$customer['customer_number'],
'name' => 'Template Driver',
'phone_country_code' => 45,
'phone' => $phone,
'permission_template_key' => 'booking_coordinator',
], $session['headers']);
$invite
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $invite->data();
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
expect($payload['subuser']['permission_template_key'] ?? null)->toBe('booking_coordinator');
expect($payload['subuser']['grant_permissions'] ?? null)
->toContain('BOOKINGS_EDIT')
->toContain('BOOKINGS_ADD')
->not->toContain('SUBUSERS_ADD');
$deactivate = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'deactivated'],
$session['headers']
);
$deactivate
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($deactivate->data()['subuser']['permission_template_key'] ?? null)->toBe('deactivated');
expect($deactivate->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($deactivate->data()['grant']['permissions'] ?? null)->toBe([]);
$invalid = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'unknown_template'],
$session['headers']
);
$invalid
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
} finally {
if ($setupToken !== null && $setupToken !== '') {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
if ($createdGrantId !== null) {
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
}
if ($createdSubuserId !== null) {
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
}
}
});
it('lists and summarizes chauffeurs through the user-scoped superuser route', function (): void {
$session = api_fixtures()->createUserSession(['list_subusers']);
$targetCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Alpha',
'economic_customer_name' => 'Scoped Customer Alpha',
]);
$otherCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Beta',
'economic_customer_name' => 'Scoped Customer Beta',
]);
$activeSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Active Driver']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Scoped Pending Driver',
'password_plaintext' => null,
]);
$disabledSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Disabled Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Other Driver']);
$activeGrantId = api_fixtures()->grantSubuser(
(int)$activeSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['BOOKINGS_LIST']
);
$disabledGrantId = api_fixtures()->grantSubuser(
(int)$disabledSubuser['id'],
(int)$targetCustomer['customer_number'],
['ORDERS_LIST']
);
api_test_runtime()->db()->query('UPDATE `subuser_grants` SET `enabled` = 0 WHERE `id` = ' . $disabledGrantId);
api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['SELFSERVE_LIST']
);
$response = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers?page=1&limit=20&include_non_enabled=true',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = is_array($response->data()) ? $response->data() : [];
$grantIds = array_map(static fn (array $row): int => (int)($row['grant_id'] ?? 0), $rows);
expect($grantIds)->toContain($activeGrantId);
expect($grantIds)->not->toContain(0);
expect($rows)->toHaveCount(3);
foreach ($rows as $row) {
expect($row['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number']);
}
expect($response->meta()['user_context']['user_id'] ?? null)->toBe((int)$targetCustomer['id']);
expect($response->meta()['subusers_summary'] ?? null)->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
expect($summary->data())->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
});
it('edits only customer-matching chauffeur grants through the user-scoped route', function (): void {
$session = api_fixtures()->createUserSession(['manage_subuser_grants']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']);
$targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']);
$targetGrantId = api_fixtures()->grantSubuser(
(int)$targetSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$otherGrantId = api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['BOOKINGS_LIST']
);
$update = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId,
[
'enabled' => false,
'note' => 'Scoped note',
'permissions' => ['ORDERS_LIST'],
],
$session['headers']
);
$crossCustomer = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId,
['note' => 'Should not save'],
$session['headers']
);
$update
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$crossCustomer
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
expect($update->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note');
expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']);
});
it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void {
$session = api_fixtures()->createUserSession(['add_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Other']);
$response = api_client()->post('/superuser/users/' . $targetCustomer['id'] . '/subusers/invite', [
'customer_number' => (int)$otherCustomer['customer_number'],
'name' => 'Mismatched Driver',
'phone_country_code' => 45,
'phone' => 71999999,
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Customer number does not match selected user');
});
it('resends pending user-scoped chauffeur invites and blocks accepted accounts', function (): void {
$session = api_fixtures()->createUserSession(['edit_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Resend Customer']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Pending Resend Driver',
'password_plaintext' => null,
]);
$acceptedSubuser = api_fixtures()->createSubuser(['name' => 'Accepted Resend Driver']);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$acceptedSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$pending = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $pendingSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$accepted = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $acceptedSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$pending
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$accepted
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
$token = $pending->data()['invite']['setup_token'] ?? null;
expect($token)->toBeString();
(new \objects\subusers_o())->invalidateSetupToken((string)$token);
});
@@ -4,6 +4,28 @@ declare(strict_types=1);
usesApiSuite();
function vehicles_without_customer_vehicles_deleted_at(callable $callback): void
{
$db = api_test_runtime()->db();
$column = $db->query("SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'");
if ($column === false) {
throw new RuntimeException('Unable to inspect customer_vehicles.deleted_at test column.');
}
$hadColumn = (int)$column->num_rows > 0;
if ($hadColumn) {
$db->query('ALTER TABLE `customer_vehicles` DROP COLUMN `deleted_at`');
}
try {
$callback();
} finally {
if ($hadColumn) {
$db->query('ALTER TABLE `customer_vehicles` ADD COLUMN `deleted_at` DATETIME NULL');
}
}
}
it('defaults wash subscriptions to false when a customer creates a vehicle without the field', function (): void {
api_test_covers('POST /vehicles', 'happy');
@@ -194,3 +216,249 @@ it('returns a null vehicle last_order_id when no order with items exists', funct
'last_order_id' => null,
]);
});
it('lists and summarizes vehicles through the user-scoped superuser route', function (): void {
api_test_covers('GET /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('GET /superuser/users/{user_id}/vehicles/summary', 'happy');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Other Vehicle Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDV1',
'wash_subscription' => 1,
'reference' => 'Scoped fleet',
]);
api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'OTHERV1',
'wash_subscription' => 1,
]);
$session = api_fixtures()->createUserSession(['list_vehicles_other']);
$response = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles?page=1&limit=20',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = $response->data();
expect(array_column($rows, 'reg'))->toContain('SCOPEDV1');
expect(array_column($rows, 'reg'))->not->toContain('OTHERV1');
expect($rows[0])->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDV1',
]);
expect($response->meta()['user_context'])->toMatchArray([
'user_id' => $targetUser['id'],
'customer_number' => $targetUser['customer_number'],
]);
expect($summary->data())->toMatchArray([
'total' => 1,
'wash_subscription' => 1,
'self_service' => 0,
]);
});
it('lists and summarizes user-scoped superuser vehicles when customer vehicles has no deleted at column', function (): void {
vehicles_without_customer_vehicles_deleted_at(function (): void {
api_test_covers('GET /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('GET /superuser/users/{user_id}/vehicles/summary', 'happy');
$customerVehiclesDeletedAtColumn = api_test_runtime()->db()->query(
"SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'"
);
expect($customerVehiclesDeletedAtColumn)->not->toBeFalse();
expect((int)$customerVehiclesDeletedAtColumn->num_rows)->toBe(0);
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Legacy Schema Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Other Vehicle Legacy Schema Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'LEGACYV1',
'wash_subscription' => 1,
'reference' => 'Legacy schema fleet',
]);
api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'LEGACYOTHER',
'wash_subscription' => 1,
]);
$session = api_fixtures()->createUserSession(['list_vehicles_other']);
$response = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles?page=1&limit=20',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = $response->data();
expect(array_column($rows, 'reg'))->toContain('LEGACYV1');
expect(array_column($rows, 'reg'))->not->toContain('LEGACYOTHER');
expect($rows[0])->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'LEGACYV1',
]);
expect($response->meta()['user_context'])->toMatchArray([
'user_id' => $targetUser['id'],
'customer_number' => $targetUser['customer_number'],
]);
expect($summary->data())->toMatchArray([
'total' => 1,
'wash_subscription' => 1,
'self_service' => 0,
]);
});
});
it('creates vehicles through the user-scoped superuser route and rejects mismatched customer ids', function (): void {
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'failure');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Create Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Mismatch Customer']);
$session = api_fixtures()->createUserSession(['add_vehicle_other']);
$created = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'type' => 53,
'reg' => 'SCOPEDADD',
'wash_subscription' => true,
'reference' => 'Created from user detail',
], $session['headers']);
$mismatch = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'BADSCOPED',
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$mismatch
->assertStatus(400)
->assertEnvelope()
->assertError();
$vehicleId = (int)($created->data()['id'] ?? 0);
expect($vehicleId)->toBeGreaterThan(0);
expect($created->data())->toMatchArray([
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDADD',
'wash_subscription' => true,
'reference' => 'Created from user detail',
]);
api_fixtures()->cleanupDeleteById('customer_vehicles', $vehicleId);
api_fixtures()->cleanupDeleteWhere('customer_vehicle_subscription_versions', ['vehicle_id' => $vehicleId]);
});
it('edits and deletes only matching customer vehicles through the user-scoped superuser route', function (): void {
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'failure');
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'failure');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Edit Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Guard Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDIT',
'wash_subscription' => 0,
]);
$otherVehicle = api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEBAD',
'wash_subscription' => 0,
]);
$deletableVehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDEL',
'wash_subscription' => 0,
]);
$session = api_fixtures()->createUserSession(['edit_vehicle_other', 'delete_vehicle_other']);
$edited = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $vehicle['id'],
'reg' => 'SCOPEDOK',
'reference' => 'Updated reference',
], $session['headers']);
$moved = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $vehicle['id'],
'customer_id' => $otherUser['customer_number'],
], $session['headers']);
$foreignEdit = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $otherVehicle['id'],
'reg' => 'SHOULDFAIL',
], $session['headers']);
$deleted = api_client()->delete(
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $deletableVehicle['id'],
null,
$session['headers']
);
$foreignDelete = api_client()->delete(
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $otherVehicle['id'],
null,
$session['headers']
);
$edited
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$moved
->assertStatus(400)
->assertEnvelope()
->assertError();
$foreignEdit
->assertStatus(404)
->assertEnvelope()
->assertError();
$deleted
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$foreignDelete
->assertStatus(404)
->assertEnvelope()
->assertError();
expect($edited->data())->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDOK',
'reference' => 'Updated reference',
]);
});
@@ -0,0 +1,267 @@
<?php
use classes\db;
use classes\department_wash_count_service;
function department_wash_count_integration_db(): db
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.');
}
$host = getenv('CONFIG_DB_HOST') ?: null;
$user = getenv('CONFIG_DB_USER') ?: null;
$password = getenv('CONFIG_DB_PASSWORD') ?: '';
$database = getenv('CONFIG_DB_DATABASE') ?: null;
if (!$host || !$user || !$database) {
test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.');
}
app_require('classes/db.php');
app_require('classes/department_wash_count_service.php');
$db = new db([
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => (int)(getenv('CONFIG_DB_PORT') ?: 3306),
]);
$db->connect();
$GLOBALS['db'] = $db;
department_wash_count_prepare_tables($db);
return $db;
}
function department_wash_count_prepare_tables(db $db): void
{
$db->query(
'CREATE TABLE IF NOT EXISTS department_lanes (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL,
name VARCHAR(255) NULL,
dynamic_image_id INT NULL,
selfserve_enabled TINYINT(1) NOT NULL DEFAULT 0
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS department_selfserve_conditions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL,
name VARCHAR(255) NULL,
product INT NULL,
action VARCHAR(64) NULL,
order_index INT NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS department_selfserve_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
condition_id INT NULL,
product INT NULL,
description TEXT NULL,
task VARCHAR(64) NULL,
buttons TEXT NULL,
order_index INT NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
price INT NOT NULL DEFAULT 0,
is_wash TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
cashier_id INT NOT NULL,
reference VARCHAR(255) NULL,
notes TEXT NULL,
department_id INT NOT NULL,
reg_1 VARCHAR(64) NULL,
reg_2 VARCHAR(64) NULL,
reg_3 VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
include_in_invoice TINYINT(1) NULL,
wash_id VARCHAR(255) NULL,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS order_items (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT NOT NULL,
product_id INT NOT NULL,
reference VARCHAR(255) NULL,
notes TEXT NULL,
cashier_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
quantity INT NOT NULL DEFAULT 1,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS selfserve_wash_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
lane_id INT NOT NULL,
department_id INT NOT NULL,
reg VARCHAR(64) NOT NULL,
status VARCHAR(64) NOT NULL,
allowed TINYINT(1) NOT NULL DEFAULT 0,
wash_started_at DATETIME NULL,
machine_start_triggered_at DATETIME NULL,
order_id INT NULL,
completed_at DATETIME NULL,
created_at DATETIME NULL,
deleted_at DATETIME NULL
)'
);
}
function department_wash_count_insert_product(db $db, string $suffix, bool $isWash): int
{
$productNameColumn = null;
if ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'name'")) > 0) {
$productNameColumn = 'name';
} elseif ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'title'")) > 0) {
$productNameColumn = 'title';
} else {
test()->markTestSkipped('Products table is missing both name and title columns required by this integration test.');
}
$escapedName = $db->escape_string('Wash count product ' . $suffix . ' ' . ($isWash ? 'wash' : 'minute'));
$descriptionSql = $db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'description'")) > 0
? ', description'
: '';
$descriptionValueSql = $descriptionSql !== '' ? ", 'Integration wash count product'" : '';
$db->query(
"INSERT INTO products ($productNameColumn$descriptionSql, is_wash)
VALUES ('$escapedName'$descriptionValueSql, " . ($isWash ? '1' : '0') . ')'
);
return (int)$db->insert_id();
}
function department_wash_count_insert_order(db $db, int $departmentId, int $productId, string $reference, string $createdAt, int $price = 100): int
{
$escapedReference = $db->escape_string($reference);
$escapedCreatedAt = $db->escape_string($createdAt);
$db->query(
"INSERT INTO orders (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, created_at)
VALUES (1, 1, '$escapedReference', 'Integration test', $departmentId, 'COUNT$departmentId', '', '', '$escapedCreatedAt')"
);
$orderId = (int)$db->insert_id();
$db->query(
"INSERT INTO order_items (order_id, product_id, reference, notes, cashier_id, price, quantity)
VALUES ($orderId, $productId, '$escapedReference', 'Integration test', 1, $price, 1)"
);
return $orderId;
}
function department_wash_count_insert_session(
db $db,
int $departmentId,
string $reg,
string $status,
?string $completedAt,
?int $orderId = null
): int {
$escapedReg = $db->escape_string($reg);
$escapedStatus = $db->escape_string($status);
$completedAtSql = $completedAt === null ? 'NULL' : "'" . $db->escape_string($completedAt) . "'";
$orderIdSql = $orderId === null ? 'NULL' : (string)$orderId;
$db->query(
"INSERT INTO selfserve_wash_sessions (lane_id, department_id, reg, status, allowed, wash_started_at, machine_start_triggered_at, order_id, completed_at, created_at)
VALUES (1, $departmentId, '$escapedReg', '$escapedStatus', 1, NULL, NULL, $orderIdSql, $completedAtSql, $completedAtSql)"
);
return (int)$db->insert_id();
}
it('counts completed self-serve minute billing sessions site-wide and dedupes linked wash orders', function (): void {
$db = department_wash_count_integration_db();
$service = new department_wash_count_service();
$suffix = (string)random_int(10000, 99999);
$departmentId = 700000 + (int)$suffix;
$orderIds = [];
$productIds = [];
$sessionIds = [];
try {
$washProductId = department_wash_count_insert_product($db, $suffix, true);
$minuteProductId = department_wash_count_insert_product($db, $suffix, false);
$productIds = [$washProductId, $minuteProductId];
$orderIds[] = department_wash_count_insert_order($db, $departmentId, $washProductId, 'plain-wash-' . $suffix, '2026-04-01 10:15:00', 100);
$linkedMinuteOrderId = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'linked-minute-' . $suffix, '2026-04-01 11:10:00', 25);
$orderIds[] = $linkedMinuteOrderId;
$linkedWashOrderId = department_wash_count_insert_order($db, $departmentId, $washProductId, 'linked-wash-' . $suffix, '2026-04-01 13:05:00', 100);
$orderIds[] = $linkedWashOrderId;
$orderIds[] = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'plain-minute-' . $suffix, '2026-04-01 14:00:00', 25);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKMIN' . $suffix, 'COMPLETED', '2026-04-01 12:00:00', $linkedMinuteOrderId);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'STAND' . $suffix, 'COMPLETED', '2026-04-01 12:20:00');
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKWASH' . $suffix, 'COMPLETED', '2026-04-01 13:30:00', $linkedWashOrderId);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'FORCE' . $suffix, 'FORCE_STOPPED', '2026-04-01 15:00:00');
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'OPEN' . $suffix, 'COMPLETED', null);
expect($service->countInDateRange('2026-04-01 00:00:00', '2026-04-01 23:59:59', $departmentId))->toBe(4);
$hourRows = $service->countByHourForDepartments('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]);
$countsByHour = [];
foreach ($hourRows as $row) {
$countsByHour[$row['hour_bucket']] = $row['wash_count'];
}
expect($countsByHour)->toMatchArray([
'2026-04-01 10:00:00' => 1,
'2026-04-01 11:00:00' => 1,
'2026-04-01 12:00:00' => 1,
'2026-04-01 13:00:00' => 1,
]);
expect($countsByHour)->not->toHaveKey('2026-04-01 14:00:00')
->and($countsByHour)->not->toHaveKey('2026-04-01 15:00:00');
$summary = $service->transactionSummary('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]);
expect($summary)->toMatchArray([
'quantity' => 4,
'products' => 4,
'earnings' => 250,
'washes' => 4,
]);
} finally {
if ($sessionIds !== []) {
$db->query('DELETE FROM selfserve_wash_sessions WHERE id IN (' . implode(',', array_map('intval', $sessionIds)) . ')');
}
if ($orderIds !== []) {
$orderIdsSql = implode(',', array_map('intval', $orderIds));
$db->query("DELETE FROM order_items WHERE order_id IN ($orderIdsSql)");
$db->query("DELETE FROM orders WHERE id IN ($orderIdsSql)");
}
if ($productIds !== []) {
$db->query('DELETE FROM products WHERE id IN (' . implode(',', array_map('intval', $productIds)) . ')');
}
$db->close();
}
});
@@ -358,6 +358,22 @@ it('persists gateway cutover relay bindings used by self-serve Shelly dispatch',
->toHaveKey('fallback_mode', edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL)
->and($context['manager']->getDepartmentTransportMode((int)$department['id']))
->toBe(edge_gateway_manager::TRANSPORT_MODE_GATEWAY);
$context['manager']->setRelayBindings($gatewayId, [], (int)$user['id']);
$recreatedBindings = $context['manager']->setRelayBindings($gatewayId, [[
'relay_id' => 'relay-machine',
'device_id' => 'device-machine-recreated',
'local_ip' => '10.50.60.71',
'channel' => 1,
]], (int)$user['id']);
expect($recreatedBindings)->toHaveCount(1)
->and($recreatedBindings[0])->toMatchArray([
'relay_id' => 'relay-machine',
'device_id' => 'device-machine-recreated',
'local_ip' => '10.50.60.71',
'channel' => 1,
]);
} finally {
$context['cleanup']->run();
}
@@ -1,11 +1,8 @@
<?php
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_cache.php');
app_require('classes/system_search_openai_intent_parser.php');
use classes\system_search_cache;
use classes\system_search_openai_intent_parser;
if (!class_exists('SystemSearchTestRedisAdapter')) {
class SystemSearchTestRedisAdapter
@@ -72,76 +69,24 @@ afterEach(function (): void {
system_search_cache::setAdapterForTests(null);
});
it('reuses parser cache entries for repeated natural-language intent requests', function (): void {
$calls = 0;
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey) use (&$calls): array {
$calls++;
return [
'output' => [
[
'content' => [
[
'text' => json_encode([
'success' => true,
'normalized_query' => 'acme unpaid invoices',
'aliases' => ['acme'],
'entity_hints' => ['invoices'],
'confidence' => 0.91,
'association_hint' => true,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
it('stores and clears query cache entries in the active system search namespace', function (): void {
$hash = md5('test-query');
$payload = [
'results' => [],
'grouped_results' => [],
'meta' => [
'query' => 'acme',
'max_results' => 50,
'returned' => 0,
'truncated' => false,
],
];
$first = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']);
$second = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']);
system_search_cache::setQuery($hash, $payload, 120);
expect(system_search_cache::getQuery($hash))->toBe($payload);
expect($first['source'])->toBe('openai');
expect($second['source'])->toBe('cache');
expect($calls)->toBe(1);
});
it('clears parser cache namespace via clearAll to force a fresh parse', function (): void {
$calls = 0;
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey) use (&$calls): array {
$calls++;
return [
'output' => [
[
'content' => [
[
'text' => json_encode([
'success' => true,
'normalized_query' => 'acme invoices',
'aliases' => ['acme'],
'entity_hints' => ['invoices'],
'confidence' => 0.8,
'association_hint' => false,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
$parser->parse('acme invoices', ['invoices']);
system_search_cache::clearAll();
$result = $parser->parse('acme invoices', ['invoices']);
expect($result['source'])->toBe('openai');
expect($calls)->toBe(2);
expect(system_search_cache::getQuery($hash))->toBeNull();
});
it('bumps per-table cache versions when a dirty-table marker is registered', function (): void {
@@ -838,6 +838,54 @@ final class ApiFixtures
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createLegacyBooking(array $attributes): array
{
$customerNumber = (int)($attributes['customer_number'] ?? 0);
$departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0);
if ($customerNumber <= 0 || $departmentId <= 0) {
throw new RuntimeException('Legacy bookings require customer_number and department.');
}
$bookingId = $this->insertRowWithExistingColumns('bookings', [
'customer_number' => $customerNumber,
'wash_type' => (string)($attributes['wash_type'] ?? 'API wash'),
'contact_email' => (string)($attributes['contact_email'] ?? 'customer@example.test'),
'reference_number' => (string)($attributes['reference_number'] ?? 'API-LEGACY-BOOKING'),
'regNrTraekker' => (string)($attributes['regNrTraekker'] ?? 'LEG123'),
'regNrTrailer' => (string)($attributes['regNrTrailer'] ?? ''),
'washCertificateEmail' => (string)($attributes['washCertificateEmail'] ?? ''),
'date' => $attributes['date'] ?? $this->now(),
'department' => $departmentId,
'pickup_bool' => (int)($attributes['pickup_bool'] ?? 0),
'notes' => (string)($attributes['notes'] ?? ''),
'washCertificateStatus' => (string)($attributes['washCertificateStatus'] ?? 'pending'),
'washCertificateUrl' => (string)($attributes['washCertificateUrl'] ?? ''),
'wash_certificate_pdf' => $attributes['wash_certificate_pdf'] ?? null,
'status' => (string)($attributes['status'] ?? 'pending'),
'data' => json_encode($attributes['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'virtual_cart' => (int)($attributes['virtual_cart'] ?? 0),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(function () use ($bookingId): void {
$this->deleteById('bookings', $bookingId);
$this->deleteRedisKey('bookings_' . $bookingId . '_asArray');
$this->deleteRedisPattern('bookings:*');
});
return [
'id' => $bookingId,
'customer_number' => $customerNumber,
'department' => $departmentId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
@@ -1789,6 +1837,7 @@ final class ApiFixtures
$this->deleteWhereIfPossible('tokens', ['user_id' => $userId]);
$this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]);
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
$this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]);
$this->deleteWhereIfPossible('limited_backoffice_employees', ['user_id' => $userId]);
$this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]);
@@ -1803,6 +1852,7 @@ final class ApiFixtures
'object_type' => 'users',
'object_id' => $userId,
]);
$this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]);
@@ -48,6 +48,11 @@ final class ApiResponse
return $this;
}
public function assertError(): self
{
return $this->assertSuccess(false);
}
public function assertMessage(string $expectedMessage): self
{
$this->assertEnvelope();
@@ -78,6 +78,23 @@ CREATE TABLE IF NOT EXISTS `groups_permissions` (
KEY `idx_groups_permissions_group_id` (`group_id`),
KEY `idx_groups_permissions_permission` (`permission`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'logs' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `logs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`module` VARCHAR(191) NOT NULL,
`department` VARCHAR(191) NULL,
`type` INT NOT NULL,
`user_id` INT NULL,
`action` VARCHAR(191) NOT NULL,
`message` TEXT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_logs_module` (`module`),
KEY `idx_logs_action` (`action`),
KEY `idx_logs_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'departments' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `departments` (
@@ -344,7 +361,9 @@ CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` (
PRIMARY KEY (`id`),
KEY `idx_selfserve_wash_sessions_lane_reg` (`lane_id`, `reg`),
KEY `idx_selfserve_wash_sessions_status` (`status`),
KEY `idx_selfserve_wash_sessions_customer` (`customer_number`)
KEY `idx_selfserve_wash_sessions_customer` (`customer_number`),
KEY `idx_selfserve_wash_sessions_department_completed` (`department_id`, `completed_at`),
KEY `idx_selfserve_wash_sessions_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'selfserve_wash_session_answers' => <<<'SQL'
@@ -536,6 +555,35 @@ CREATE TABLE IF NOT EXISTS `orders` (
KEY `idx_orders_period_customer_created_deleted` (`customer_id`, `created_at`, `deleted_at`),
KEY `idx_orders_period_created_deleted_customer` (`created_at`, `deleted_at`, `customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'bookings' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `bookings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_number` INT NOT NULL,
`wash_type` VARCHAR(255) NULL,
`contact_email` VARCHAR(255) NULL,
`reference_number` VARCHAR(255) NULL,
`regNrTraekker` VARCHAR(32) NULL,
`regNrTrailer` VARCHAR(32) NULL,
`washCertificateEmail` VARCHAR(255) NULL,
`date` DATETIME NULL,
`department` INT NOT NULL DEFAULT 0,
`pickup_bool` TINYINT(1) NOT NULL DEFAULT 0,
`notes` TEXT NULL,
`washCertificateStatus` VARCHAR(32) NULL,
`washCertificateUrl` TEXT NULL,
`wash_certificate_pdf` VARCHAR(255) NULL,
`status` VARCHAR(32) NULL,
`data` LONGTEXT NULL,
`virtual_cart` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_bookings_customer_number` (`customer_number`),
KEY `idx_bookings_department` (`department`),
KEY `idx_bookings_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'order_bookings' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `order_bookings` (
@@ -781,6 +829,23 @@ CREATE TABLE IF NOT EXISTS `price_overrides` (
KEY `idx_price_overrides_user_id` (`user_id`),
KEY `idx_price_overrides_lookup` (`user_id`, `is_category`, `product_or_category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_customer_price_overrides' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`user_id` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'products_options' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `products_options` (
@@ -14,6 +14,10 @@ class ApiTestCase extends TestCase
$skipReason = \api_test_runtime()->skipReason();
if ($skipReason !== null) {
if (getenv('API_TEST_FAIL_ON_SKIP') === '1') {
throw new \RuntimeException($skipReason);
}
$this->markTestSkipped($skipReason);
}
@@ -52,6 +52,17 @@ function assert_api_envelope(ApiResponse $response): ApiResponse
function edge_test_broker_secret(): string
{
try {
$configured = api_test_runtime()->queryOne(
"SELECT `value` FROM `module_config` WHERE `module` = 'edgegateway' AND `variable` = 'broker_shared_secret' LIMIT 1"
);
$secret = trim((string)($configured['value'] ?? ''));
if ($secret !== '') {
return $secret;
}
} catch (\Throwable) {
}
$secret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
return $secret !== '' ? $secret : 'truckwash-edge-test-secret';
@@ -23,9 +23,14 @@ $commonEnv = [
'REDIS_CONFIG_DEBUG_HOST' => 'redis',
'REDIS_CONFIG_DEBUG_PORT' => '6379',
'REDIS_CONFIG_DEBUG_DATABASE' => '0',
'EDGE_BROKER_URL' => 'http://edge-broker:4300',
'EDGE_PUBLIC_BROKER_URL' => '',
'EDGE_BROKER_SHARED_SECRET' => 'truckwash-edge-ci',
'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0',
'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1',
'MINIO_ENDPOINT' => '',
'MINIO_ACCESS_KEY' => '',
'MINIO_SECRET_KEY' => '',
];
foreach ($commonEnv as $key => $value) {
@@ -42,13 +47,31 @@ $commands = [
'RUN_INTEGRATION_TESTS=1 vendor/bin/pest --testsuite=Integration --colors=always',
],
'api' => [
'RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Api --colors=always',
'RUN_API_TESTS=1 API_TEST_FAIL_ON_SKIP=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Api --colors=always',
],
'legacy' => [
'RUN_LEGACY_TESTS=1 RUN_INTEGRATION_TESTS=1 RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Legacy --colors=always',
'RUN_LEGACY_TESTS=1 RUN_INTEGRATION_TESTS=1 RUN_API_TESTS=1 API_TEST_FAIL_ON_SKIP=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Legacy --colors=always',
],
];
/**
* @param array<int, string> $extensions
*/
function assert_required_php_extensions(array $extensions): void
{
$missing = array_values(array_filter(
$extensions,
static fn (string $extension): bool => !extension_loaded($extension)
));
if ($missing === []) {
return;
}
fwrite(STDERR, 'Missing required PHP extension(s): ' . implode(', ', $missing) . PHP_EOL);
exit(1);
}
function reset_ci_state(): void
{
$database = getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug';
@@ -86,6 +109,10 @@ function reset_ci_state(): void
if ($suite === 'all') {
foreach (['unit', 'integration', 'api', 'legacy'] as $selectedSuite) {
if (in_array($selectedSuite, ['api', 'legacy'], true)) {
assert_required_php_extensions(['mysqli']);
}
reset_ci_state();
foreach ($commands[$selectedSuite] as $command) {
passthru($command, $exitCode);
@@ -102,6 +129,10 @@ if ($suite === 'all') {
exit(2);
}
if (in_array($suite, ['api', 'legacy'], true)) {
assert_required_php_extensions(['mysqli']);
}
foreach ($selectedCommands as $command) {
passthru($command, $exitCode);
if ($exitCode !== 0) {
@@ -392,5 +392,8 @@ it('wires the overview route to batched repository methods and overview path', f
expect($routeContent)->toContain('/departments/daily-reports/outside-hours-trend');
expect($routeContent)->toContain('getTransactionSummaryForDepartments');
expect($routeContent)->toContain('normalizeDepartmentIdsParameter');
expect($objectContent)->toContain('department_wash_count_service');
expect($objectContent)->toContain('countInDateRange');
expect($objectContent)->toContain('listTransactions');
expect($objectContent)->toContain('public function getBookingSummaryForDepartments');
});
@@ -0,0 +1,138 @@
<?php
app_require('classes/department_wash_count_service.php');
use classes\department_wash_count_service;
final class DepartmentWashCountFakeResult
{
public int $num_rows;
private int $cursor = 0;
public function __construct(private readonly array $rows)
{
$this->num_rows = count($rows);
}
public function fetch_assoc(): ?array
{
if (!array_key_exists($this->cursor, $this->rows)) {
return null;
}
return $this->rows[$this->cursor++];
}
}
final class DepartmentWashCountFakeDb
{
public array $queries = [];
public function escape_string(string $value): string
{
return addslashes($value);
}
public function getDatabase(): string
{
return 'test_db';
}
public function query(string $sql): DepartmentWashCountFakeResult
{
$this->queries[] = $sql;
if (str_contains($sql, 'information_schema.COLUMNS') || str_contains($sql, 'information_schema.STATISTICS')) {
return new DepartmentWashCountFakeResult([['c' => 1, 'DATA_TYPE' => 'text']]);
}
if (str_contains($sql, 'COUNT(DISTINCT o.id) AS quantity')) {
return new DepartmentWashCountFakeResult([[
'quantity' => 3,
'products' => 5,
'earnings' => 250,
]]);
}
return new DepartmentWashCountFakeResult([[
'department_id' => 7,
'hour_bucket' => '2026-04-01 12:00:00',
'wash_count' => 2,
]]);
}
}
function department_wash_count_find_query(array $queries, string $needle): string
{
foreach ($queries as $query) {
if (str_contains($query, $needle)) {
return $query;
}
}
return '';
}
it('builds wash counts from order washes and completed self-serve sessions with linked-order dedupe', function (): void {
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$fakeDb = new DepartmentWashCountFakeDb();
$GLOBALS['db'] = $fakeDb;
try {
$service = new department_wash_count_service();
$rows = $service->countByHourForDepartments('2026-04-01 00:00:00', '2026-04-01 23:59:59', [7]);
expect($rows)->toBe([[
'department_id' => 7,
'hour_bucket' => '2026-04-01 12:00:00',
'wash_count' => 2,
]]);
$query = department_wash_count_find_query($fakeDb->queries, 'FROM selfserve_wash_sessions s');
expect($query)->toContain('FROM selfserve_wash_sessions s')
->and($query)->toContain("UPPER(TRIM(s.status)) = 'COMPLETED'")
->and($query)->toContain('s.completed_at IS NOT NULL')
->and($query)->toContain('COALESCE(linked_o.created_at, s.completed_at)')
->and($query)->toContain("CONCAT('order:', linked_o.id)")
->and($query)->toContain('GROUP BY dedupe_key, department_id');
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('keeps transaction totals order-based while using expanded wash counts', function (): void {
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$fakeDb = new DepartmentWashCountFakeDb();
$GLOBALS['db'] = $fakeDb;
try {
$service = new department_wash_count_service();
$summary = $service->transactionSummary('2026-04-01 00:00:00', '2026-04-01 23:59:59', [7]);
expect($summary)->toBe([
'quantity' => 3,
'products' => 5,
'earnings' => 250,
'washes' => 2,
]);
$summaryQuery = department_wash_count_find_query($fakeDb->queries, 'COUNT(DISTINCT o.id) AS quantity');
$countQuery = department_wash_count_find_query($fakeDb->queries, 'FROM selfserve_wash_sessions s');
expect($summaryQuery)->toContain('COUNT(DISTINCT o.id) AS quantity')
->and($summaryQuery)->not->toContain('selfserve_wash_sessions')
->and($countQuery)->toContain('selfserve_wash_sessions');
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
@@ -26,7 +26,7 @@ if (!class_exists('FakeEconomicV2BookedDepartment75VersioningService')) {
return $this->subscriptionVersions;
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -27,7 +27,7 @@ if (!class_exists('FakeEconomicV2DistributionVersioningService')) {
return $this->subscriptionVersions;
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -97,7 +97,7 @@ if (!class_exists('TestableEconomicV2DistributionService')) {
return 100.0;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -23,7 +23,7 @@ if (!class_exists('EconomicV2DistributionServiceOrderOverrideVersioningDouble'))
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -23,7 +23,7 @@ if (!class_exists('FakeEconomicV2ProductFixedPriceVersioningService')) {
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
if (!$is_category && (int)$object_id === 42) {
return [
@@ -99,6 +99,21 @@ if (!class_exists('TestableEconomicV2ProductFixedPriceDistributionService')) {
return 1000.0;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
if ($product_id !== 42) {
return null;
}
return [
'customer_number' => $customer_number,
'is_category' => 0,
'object_id' => '42',
'discount' => 10,
'fixed_price' => 350,
];
}
protected function isOrderEligible(array $order): bool
{
return true;
@@ -41,5 +41,5 @@ it('anchors historical resolution on order created_at timestamps in distribution
expect($content)->not->toBeFalse();
expect($content)->toContain('resolveFixedPricingVersionAt($customer_number, $created_at)');
expect($content)->toContain('resolveVehicleSubscriptionVersionsAt($customer_number, $created_at)');
expect($content)->toContain('resolveDiscountForProduct($customer_number, $product_id, $created_at)');
expect($content)->toContain('resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id)');
});
@@ -0,0 +1,15 @@
<?php
it('does not query subscription-applied transactions with an empty eligible order list', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/objects/customer_vehicles_o.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)
->toContain('$orders = [];')
->toMatch('/\$orders\[\] = \(int\)\$transaction_id;\s*}\s*if \(empty\(\$orders\)\) {\s*return \[\];\s*}\s*\$order_ids = implode/s')
->toContain('AND o.id IN ($order_ids)')
->not->toContain('rtrim($orders, \',\')')
->not->toContain('AND o.id IN ($orders)');
});
@@ -99,6 +99,31 @@ if (!class_exists('OrderBookingsCompletionDouble')) {
}
}
if (!class_exists('OrderBookingsCompletionRefreshDouble')) {
class OrderBookingsCompletionRefreshDouble extends OrderBookingsCompletionDouble
{
public orders_o $refreshedOrder;
public function __construct(orders_o $initialOrder, orders_o $refreshedOrder)
{
parent::__construct($initialOrder);
$this->refreshedOrder = $refreshedOrder;
}
public function getOrder(): orders_o
{
return $this->attachCalls > 0 ? $this->refreshedOrder : $this->linkedOrder;
}
protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void
{
$this->attachCalls++;
$this->refreshedOrder->washCertificateAttached = true;
$this->refreshedOrder->safety_seal->set($safety_seal);
}
}
}
it('attaches and emails a wash certificate when a booking is already linked to a matching pos order without one', function (): void {
$order = new OrderBookingsCompletionOrderDouble();
$booking = new OrderBookingsCompletionDouble($order);
@@ -112,6 +137,22 @@ it('attaches and emails a wash certificate when a booking is already linked to a
expect($booking->sendCalls)->toBe(1);
});
it('reloads the linked order before deciding whether to email a newly attached wash certificate', function (): void {
$initialOrder = new OrderBookingsCompletionOrderDouble();
$refreshedOrder = new OrderBookingsCompletionOrderDouble();
$booking = new OrderBookingsCompletionRefreshDouble($initialOrder, $refreshedOrder);
$booking->order_id->set(321);
$booking->containsWashCertificate = true;
$booking->completeBooking(77, 'REFRESH-SEAL');
expect($initialOrder->washCertificateAttached)->toBeFalse();
expect($refreshedOrder->washCertificateAttached)->toBeTrue();
expect($refreshedOrder->getSafetySealValue())->toBe('REFRESH-SEAL');
expect($booking->attachCalls)->toBe(1);
expect($booking->sendCalls)->toBe(1);
});
it('rejects wash certificate completion when the linked pos order belongs to another booking context', function (): void {
$order = new OrderBookingsCompletionOrderDouble();
$order->customer_id->set(222222);
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use routes\productsRoute;
function products_route_customer_booking_department_pricing_allowed(
bool $isCustomerBookingSession,
bool $useFinalPrice,
?int $customerId,
bool $isOwnCustomer = true
): bool {
$route = new class ($isOwnCustomer) extends productsRoute {
public function __construct(private readonly bool $isOwnCustomer)
{
parent::__construct();
}
public function isOwnCustomerContext(int $targetCustomerNumber): bool
{
return $this->isOwnCustomer;
}
};
$method = new ReflectionMethod(productsRoute::class, 'canUseCustomerBookingDepartmentPricing');
return (bool)$method->invoke($route, $isCustomerBookingSession, $useFinalPrice, $customerId);
}
it('allows customer booking final department pricing for the authenticated customer', function (): void {
expect(products_route_customer_booking_department_pricing_allowed(true, true, 35131752, true))
->toBeTrue();
});
it('allows customer booking final department pricing when no customer id is requested', function (): void {
expect(products_route_customer_booking_department_pricing_allowed(true, true, null))
->toBeTrue();
});
it('does not allow customer booking department pricing for another customer', function (): void {
expect(products_route_customer_booking_department_pricing_allowed(true, true, 35131752, false))
->toBeFalse();
});
it('does not bypass department access outside final price booking reads', function (): void {
expect(products_route_customer_booking_department_pricing_allowed(true, false, 35131752, true))
->toBeFalse()
->and(products_route_customer_booking_department_pricing_allowed(false, true, 35131752, true))
->toBeFalse();
});
@@ -1,35 +1,14 @@
<?php
app_require('routes/systemSearchRoute.php');
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_document_index.php');
app_require('classes/system_search_economic_customer_index.php');
app_require('classes/system_search_registry.php');
app_require('classes/system_search_service.php');
use classes\system_search_service;
use interfaces\system_search_intent_parser_i;
use routes\systemSearchRoute;
if (!class_exists('SystemSearchNullIntentParserForCoverage')) {
class SystemSearchNullIntentParserForCoverage implements system_search_intent_parser_i
{
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => 'disabled',
'source' => 'none',
];
}
}
}
function system_search_entity_coverage_invoke_private(object $instance, string $method): array
{
$reflection = new ReflectionClass($instance);
@@ -69,7 +48,7 @@ it('keeps route and service entity type registries in sync with expanded coverag
$route = new systemSearchRoute();
$routeTypes = system_search_entity_coverage_invoke_private($route, 'allEntityTypes');
$service = new system_search_service(new SystemSearchNullIntentParserForCoverage());
$service = new system_search_service();
$serviceTypes = system_search_entity_coverage_invoke_private($service, 'allEntityTypes');
$routeNormalized = array_values(array_unique(array_map('strval', $routeTypes)));
@@ -91,7 +70,7 @@ it('documents every supported search entity type in openapi enum', function ():
$_SERVER['REQUEST_URI'] = '/search/system';
$content = system_search_openapi_content_or_skip_for_coverage();
$service = new system_search_service(new SystemSearchNullIntentParserForCoverage());
$service = new system_search_service();
$types = system_search_entity_coverage_invoke_private($service, 'allEntityTypes');
foreach ($types as $type) {
expect($content)->toContain('- ' . $type);
@@ -1,241 +0,0 @@
<?php
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_cache.php');
app_require('classes/system_search_openai_intent_parser.php');
use classes\system_search_cache;
use classes\system_search_openai_intent_parser;
if (!class_exists('SystemSearchTestRedisAdapter')) {
class SystemSearchTestRedisAdapter
{
private array $store = [];
public function reset(): void
{
$this->store = [];
}
public function get(string $key): mixed
{
return $this->store[$key] ?? null;
}
public function set(string $key, string $value): void
{
$this->store[$key] = $value;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function delete(string $key): void
{
unset($this->store[$key]);
}
public function expire(string $key, int $ttl): void
{
// TTL is not simulated in unit tests.
}
public function set_array(string $key, array $value): void
{
$this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE);
}
public function get_array(string $key): ?array
{
$value = $this->store[$key] ?? null;
if (!is_string($value)) {
return null;
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key)) {
unset($this->store[$key]);
}
}
}
}
}
beforeEach(function (): void {
system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter());
});
afterEach(function (): void {
system_search_cache::setAdapterForTests(null);
});
it('redacts obvious sensitive fragments before sending query to intent parser', function (): void {
$query = 'Contact alice@example.com at +45 12 34 56 78, cvr 12345678, order 987654, reg AB12345, id 550e8400-e29b-41d4-a716-446655440000';
$redacted = system_search_openai_intent_parser::redactSensitiveQuery($query);
expect($redacted)->toContain('[email]');
expect($redacted)->toContain('[phone]');
expect($redacted)->toContain('[cvr]');
expect($redacted)->toContain('order [id]');
expect($redacted)->toContain('[plate]');
expect($redacted)->toContain('[uuid]');
expect($redacted)->not->toContain('alice@example.com');
expect($redacted)->not->toContain('12345678');
expect($redacted)->not->toContain('987654');
expect($redacted)->not->toContain('AB12345');
});
it('builds payload with redacted query and parses strict JSON output', function (): void {
$capturedPrompt = '';
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey) use (&$capturedPrompt): array {
$capturedPrompt = (string)($payload['input'][0]['content'][0]['text'] ?? '');
return [
'output' => [
[
'content' => [
[
'text' => json_encode([
'success' => true,
'normalized_query' => 'acme unpaid invoices',
'aliases' => ['acme', 'invoice overdue'],
'entity_hints' => ['customers', 'invoices'],
'confidence' => 0.93,
'association_hint' => true,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
$result = $parser->parse(
'find unpaid invoices for alice@example.com',
['customers', 'invoices'],
['customers' => ['account'], 'invoices' => ['billing']]
);
expect($capturedPrompt)->toContain('[email]');
expect($capturedPrompt)->not->toContain('alice@example.com');
expect($result['success'])->toBeTrue();
expect($result['source'])->toBe('openai');
expect($result['confidence'])->toBe(0.93);
expect($result['entity_hints'])->toBe(['customers', 'invoices']);
});
it('falls back safely when OpenAI is disabled', function (): void {
$parser = new system_search_openai_intent_parser(null, false, null);
$result = $parser->parse('find acme invoices', ['customers', 'invoices']);
expect($result['success'])->toBeFalse();
expect($result['source'])->toBe('none');
expect($result['fallback_reason'])->toBe('openai_disabled');
});
it('handles malformed OpenAI response payloads without throwing', function (): void {
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey): array {
return ['output' => []];
},
true,
'test-key'
);
$result = $parser->parse('find acme invoices', ['invoices']);
expect($result['success'])->toBeFalse();
expect($result['source'])->toBe('openai');
expect((string)$result['fallback_reason'])->toContain('Invalid response format');
});
it('uses parser cache for identical query and allowed type combinations', function (): void {
$calls = 0;
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey) use (&$calls): array {
$calls++;
return [
'output' => [
[
'content' => [
[
'text' => json_encode([
'success' => true,
'normalized_query' => 'acme invoices',
'aliases' => ['acme'],
'entity_hints' => ['invoices'],
'confidence' => 0.8,
'association_hint' => false,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
$first = $parser->parse('acme invoices', ['invoices']);
$second = $parser->parse('acme invoices', ['invoices']);
expect($first['source'])->toBe('openai');
expect($second['source'])->toBe('cache');
expect($calls)->toBe(1);
});
it('caps alias and hint payloads from OpenAI and filters hints to allowed types', function (): void {
$manyAliases = [];
for ($i = 0; $i < 40; $i++) {
$manyAliases[] = 'ALIAS_' . $i . '_' . str_repeat('x', 90);
}
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey) use ($manyAliases): array {
return [
'output' => [
[
'content' => [
[
'text' => json_encode([
'success' => true,
'normalized_query' => str_repeat('q', 500),
'aliases' => $manyAliases,
'entity_hints' => ['orders', 'invoices', 'made_up_type'],
'confidence' => 0.7,
'association_hint' => false,
'fallback_reason' => null,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
$result = $parser->parse('acme', ['orders', 'invoices']);
expect(count($result['aliases']))->toBeLessThanOrEqual(12);
$longestAlias = 0;
foreach ($result['aliases'] as $alias) {
$longestAlias = max($longestAlias, mb_strlen((string)$alias));
}
expect($longestAlias)->toBeLessThanOrEqual(64);
expect(mb_strlen((string)$result['normalized_query']))->toBeLessThanOrEqual(256);
expect($result['entity_hints'])->toBe(['orders', 'invoices']);
});
@@ -33,12 +33,13 @@ it('documents system-wide search endpoints in openapi', function (): void {
expect($content)->toContain('/superuser/search/system/cache/rebuild:');
});
it('documents debug_intent and parser metadata schema in openapi', function (): void {
it('documents max_results and omits pagination and intent parser schemas in openapi', function (): void {
$content = system_search_openapi_content_or_skip();
expect($content)->toContain('debug_intent:');
expect($content)->toContain('SystemSearchIntentParserMeta:');
expect($content)->toContain('intent_parser:');
expect($content)->toContain('max_results:');
expect($content)->not->toContain('debug_intent:');
expect($content)->not->toContain('SystemSearchIntentParserMeta:');
expect($content)->not->toContain('intent_parser:');
expect($content)->toContain('SystemSearchResponse:');
});
@@ -47,5 +48,6 @@ it('documents e-conomic indexed customer matching and synonym behavior', functio
expect($content)->toContain('local e-conomic customer index');
expect($content)->toContain('`rabat` -> `discount`');
expect($content)->toContain('strict relevance filtering');
expect($content)->toContain('recent records preferred when relevance is comparable');
});
@@ -41,6 +41,11 @@ it('parses booleans and clamps integers using route defaults', function (): void
expect(system_search_route_invoke_private($route, 'clampInt', [0, 1, 200, 50]))->toBe(50);
expect(system_search_route_invoke_private($route, 'clampInt', [999, 1, 200, 50]))->toBe(200);
expect(system_search_route_invoke_private($route, 'clampInt', [-4, 1, 200, 50]))->toBe(1);
expect(system_search_route_invoke_private($route, 'clampMaxResults', [0]))->toBe(50);
expect(system_search_route_invoke_private($route, 'clampMaxResults', [999]))->toBe(50);
expect(system_search_route_invoke_private($route, 'clampMaxResults', [-4]))->toBe(1);
expect(system_search_route_invoke_private($route, 'clampMaxResults', [12]))->toBe(12);
});
it('exposes expected searchable entity types', function (): void {
@@ -1,15 +1,17 @@
<?php
it('registers system-wide search GET and POST endpoints with intent debug support', function (): void {
it('registers system-wide search GET and POST endpoints with capped relevance support', function (): void {
$routeFile = app_path('routes/systemSearchRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/search/system');
expect($content)->toContain("'debug_intent'");
expect($content)->toContain("'max_results'");
expect($content)->toContain("'include_types'");
expect($content)->toContain("'exclude_types'");
expect($content)->toContain('new system_search_service()');
expect($content)->not->toContain("\$params['limit']");
expect($content)->not->toContain("'debug_intent'");
});
it('registers superuser cache clear and rebuild endpoints for system search', function (): void {
@@ -21,7 +23,7 @@ it('registers superuser cache clear and rebuild endpoints for system search', fu
expect($content)->toContain('/superuser/search/system/cache/rebuild');
expect($content)->toContain("requirePermission('superuser_search_system_cache_clear')");
expect($content)->toContain("requirePermission('superuser_search_system_cache_rebuild')");
expect($content)->toContain('system_search_cache::clearIntentCaches()');
expect($content)->not->toContain('system_search_cache::clearIntentCaches()');
});
it('passes permission and own-scope context into system search service', function (): void {
@@ -1,992 +0,0 @@
<?php
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_cache.php');
app_require('classes/system_search_document_index.php');
app_require('classes/system_search_economic_customer_index.php');
app_require('classes/system_search_openai_intent_parser.php');
app_require('classes/system_search_registry.php');
app_require('classes/system_search_service.php');
use classes\system_search_cache;
use classes\system_search_economic_customer_index;
use classes\system_search_service;
use interfaces\system_search_intent_parser_i;
if (!class_exists('SystemSearchTestRedisAdapter')) {
class SystemSearchTestRedisAdapter
{
private array $store = [];
public function get(string $key): mixed
{
return $this->store[$key] ?? null;
}
public function set(string $key, string $value): void
{
$this->store[$key] = $value;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function delete(string $key): void
{
unset($this->store[$key]);
}
public function expire(string $key, int $ttl): void
{
// TTL is not simulated in unit tests.
}
public function set_array(string $key, array $value): void
{
$this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE);
}
public function get_array(string $key): ?array
{
$value = $this->store[$key] ?? null;
if (!is_string($value)) {
return null;
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key)) {
unset($this->store[$key]);
}
}
}
}
}
if (!class_exists('FakeSystemSearchIntentParser')) {
class FakeSystemSearchIntentParser implements system_search_intent_parser_i
{
public int $calls = 0;
/**
* @var array<int, array<string, mixed>>
*/
public array $responses = [];
public function __construct(array $responses = [])
{
$this->responses = $responses;
}
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
$this->calls++;
if (empty($this->responses)) {
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => 'no_response',
'source' => 'none',
];
}
return array_shift($this->responses);
}
}
}
if (!class_exists('TestableSystemSearchService')) {
class TestableSystemSearchService extends system_search_service
{
/**
* @var array<int, array<string, mixed>>
*/
public array $lexicalCalls = [];
/**
* @var array<int, array<int, array<string, mixed>>>
*/
private array $queuedLexicalResults;
public function __construct(system_search_intent_parser_i $intentParser, array $queuedLexicalResults)
{
$this->queuedLexicalResults = $queuedLexicalResults;
parent::__construct($intentParser);
}
protected function executeLexicalSearch(
array $activeTypes,
array $terms,
array $entityBoost,
array $ownOnlyTypes,
?int $ownCustomerNumber,
array $permissionsCatalogAll,
array $permissionsCatalogOwn,
array $moduleConfigVisibility,
array $allowedDepartmentIds = [],
array $forcedCustomerNumbers = []
): array {
$this->lexicalCalls[] = [
'activeTypes' => $activeTypes,
'terms' => $terms,
'entityBoost' => $entityBoost,
'ownOnlyTypes' => $ownOnlyTypes,
'ownCustomerNumber' => $ownCustomerNumber,
'allowedDepartmentIds' => $allowedDepartmentIds,
'forcedCustomerNumbers' => $forcedCustomerNumbers,
];
if (empty($this->queuedLexicalResults)) {
return [];
}
return array_shift($this->queuedLexicalResults);
}
}
}
if (!class_exists('CustomerContextAwareTestableSystemSearchService')) {
class CustomerContextAwareTestableSystemSearchService extends TestableSystemSearchService
{
/**
* @var array<int, array<string, mixed>>
*/
public array $customerContexts = [];
protected function loadCustomerContexts(array $customerNumbers): array
{
$contexts = [];
foreach ($customerNumbers as $customerNumber) {
$normalized = (int)$customerNumber;
if ($normalized <= 0 || !isset($this->customerContexts[$normalized])) {
continue;
}
$contexts[$normalized] = $this->customerContexts[$normalized];
}
return $contexts;
}
}
}
if (!function_exists('system_search_service_invoke_private')) {
function system_search_service_invoke_private(object $instance, string $method, array $args = []): mixed
{
$reflection = new ReflectionMethod($instance, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($instance, $args);
}
}
beforeEach(function (): void {
system_search_cache::setAdapterForTests(null);
});
it('does not invoke intent parser when lexical confidence is already high', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'ignored',
'aliases' => ['ignored'],
'entity_hints' => ['orders'],
'confidence' => 0.99,
'association_hint' => false,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [[
['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 95],
['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'Order #2', 'score' => 90],
['entity_type' => 'orders', 'entity_id' => '3', 'title' => 'Order #3', 'score' => 88],
['entity_type' => 'orders', 'entity_id' => '4', 'title' => 'Order #4', 'score' => 84],
['entity_type' => 'orders', 'entity_id' => '5', 'title' => 'Order #5', 'score' => 80],
]]);
$result = $service->search([
'query' => 'order 1',
'allowed_types' => ['orders'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(0);
expect($result['meta']['intent_parser']['status'])->toBe('skipped');
expect(count($service->lexicalCalls))->toBe(1);
});
it('invokes parser on low-confidence lexical results and applies hints-only boosts', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'acme invoices',
'aliases' => ['acme corp', 'invoice overdue'],
'entity_hints' => ['orders'],
'confidence' => 0.87,
'association_hint' => true,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 20]],
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 85]],
]);
$result = $service->search([
'query' => 'acm inv',
'allowed_types' => ['orders', 'customers'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect(count($service->lexicalCalls))->toBe(2);
expect($service->lexicalCalls[1]['entityBoost']['orders'] ?? 0)->toBe(25);
expect(implode(' ', $service->lexicalCalls[1]['terms']))->toContain('acme');
expect($result['meta']['intent_parser']['status'])->toBe('ok');
expect($result['meta']['intent_parser']['source'])->toBe('openai');
});
it('never lets parser entity hints override explicit include filters', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'customer acme',
'aliases' => ['acme'],
'entity_hints' => ['orders'],
'confidence' => 0.7,
'association_hint' => true,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 10]],
[['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 90]],
]);
$result = $service->search([
'query' => 'acm',
'include_types' => ['customers'],
'allowed_types' => ['customers', 'orders'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect($service->lexicalCalls[0]['activeTypes'])->toBe(['customers']);
expect($service->lexicalCalls[1]['activeTypes'])->toBe(['customers']);
expect($result['results'][0]['entity_type'])->toBe('customers');
});
it('returns fallback parser metadata when parser fails gracefully', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => 'openai_disabled',
'source' => 'none',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]],
]);
$result = $service->search([
'query' => 'unknown phrase',
'allowed_types' => ['orders'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect($result['meta']['intent_parser']['status'])->toBe('fallback');
expect($result['meta']['intent_parser']['fallback_reason'])->toBe('openai_disabled');
});
it('scopes query cache by permission context to avoid cross-user cache leakage', function (): void {
system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter());
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'first', 'score' => 95]],
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'second', 'score' => 96]],
]);
$first = $service->search([
'query' => 'acme',
'allowed_types' => ['orders'],
'include_associations' => false,
'permissions_catalog_own' => ['list_orders'],
'module_config_visibility' => ['openAI' => true],
]);
$second = $service->search([
'query' => 'acme',
'allowed_types' => ['orders'],
'include_associations' => false,
'permissions_catalog_own' => ['list_own_orders'],
'module_config_visibility' => ['openAI' => false],
]);
expect(count($service->lexicalCalls))->toBe(2);
expect($first['results'][0]['entity_id'])->toBe('1');
expect($second['results'][0]['entity_id'])->toBe('2');
});
it('caps AI-driven expanded terms to prevent query amplification', function (): void {
$aliases = [];
for ($i = 0; $i < 80; $i++) {
$aliases[] = 'alias_' . $i . '_' . str_repeat('x', 90);
}
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => str_repeat('n', 500),
'aliases' => $aliases,
'entity_hints' => ['orders'],
'confidence' => 0.8,
'association_hint' => false,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]],
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 80]],
]);
$service->search([
'query' => 'a b',
'allowed_types' => ['orders'],
'include_associations' => false,
'debug_intent' => true,
]);
$expanded = $service->lexicalCalls[1]['terms'] ?? [];
expect(count($expanded))->toBeLessThanOrEqual(24);
$maxLen = 0;
foreach ($expanded as $term) {
$maxLen = max($maxLen, mb_strlen((string)$term));
}
expect($maxLen)->toBeLessThanOrEqual(64);
});
it('passes allowed department ids into lexical execution context', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 70],
]]);
$service->search([
'query' => 'order',
'allowed_types' => ['orders'],
'include_associations' => false,
'allowed_department_ids' => [3, '7', 3],
]);
expect(count($service->lexicalCalls))->toBe(1);
expect($service->lexicalCalls[0]['allowedDepartmentIds'])->toBe([3, 7]);
});
it('only applies indexed department filters to department-scoped indexed entities', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['orders']))->toBeTrue();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['objects']))->toBeTrue();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['bookings']))->toBeTrue();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['customers']))->toBeFalse();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['invoices']))->toBeFalse();
});
it('does not expand associations for own-only entity types', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [
[[
'entity_type' => 'customers',
'entity_id' => '10',
'title' => 'Acme',
'customer_number' => 1234,
'score' => 80,
]],
[],
]);
$service->search([
'query' => 'acme',
'allowed_types' => ['customers', 'orders'],
'own_only_types' => ['orders'],
'own_customer_number' => 4444,
'include_associations' => true,
]);
expect(count($service->lexicalCalls))->toBe(1);
});
it('expands danish discount wording into lexical discount synonyms', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95],
['entity_type' => 'customer_discounts', 'entity_id' => '2', 'title' => 'd2', 'score' => 92],
['entity_type' => 'customer_discounts', 'entity_id' => '3', 'title' => 'd3', 'score' => 90],
['entity_type' => 'customer_discounts', 'entity_id' => '4', 'title' => 'd4', 'score' => 88],
['entity_type' => 'customer_discounts', 'entity_id' => '5', 'title' => 'd5', 'score' => 86],
]]);
$service->search([
'query' => 'pleno rabat',
'allowed_types' => ['customer_discounts'],
'include_associations' => false,
]);
$terms = $service->lexicalCalls[0]['terms'] ?? [];
expect($parser->calls)->toBeGreaterThanOrEqual(1);
expect($terms)->toContain('rabat');
expect($terms)->toContain('discount');
});
it('invokes parser for intent-driven natural-language queries even when lexical score is high', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'acme customer discount',
'aliases' => ['discount', 'price override'],
'entity_hints' => ['customer_discounts'],
'confidence' => 0.82,
'association_hint' => true,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[
['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95],
['entity_type' => 'customer_discounts', 'entity_id' => '2', 'title' => 'd2', 'score' => 92],
['entity_type' => 'customer_discounts', 'entity_id' => '3', 'title' => 'd3', 'score' => 90],
['entity_type' => 'customer_discounts', 'entity_id' => '4', 'title' => 'd4', 'score' => 88],
['entity_type' => 'customer_discounts', 'entity_id' => '5', 'title' => 'd5', 'score' => 86],
],
[
['entity_type' => 'customer_discounts', 'entity_id' => '10', 'title' => 'improved', 'score' => 97],
],
]);
$result = $service->search([
'query' => 'show me acme rabat options',
'allowed_types' => ['customer_discounts'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect(count($service->lexicalCalls))->toBe(2);
expect($result['meta']['intent_parser']['status'])->toBe('ok');
});
it('uses association hints to pull related customer records from non-customer matches', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'pleno customer discount',
'aliases' => ['discount'],
'entity_hints' => ['customer_discounts', 'orders'],
'confidence' => 0.85,
'association_hint' => true,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[
[
'entity_type' => 'customer_discounts',
'entity_id' => '44',
'title' => 'Discount #44',
'customer_number' => 777,
'score' => 12,
],
],
[
[
'entity_type' => 'customer_discounts',
'entity_id' => '44',
'title' => 'Discount #44',
'customer_number' => 777,
'score' => 95,
],
],
[
[
'entity_type' => 'orders',
'entity_id' => '9001',
'title' => 'Order #9001',
'customer_number' => 777,
'score' => 40,
],
],
]);
$result = $service->search([
'query' => 'pleno rabat',
'allowed_types' => ['customer_discounts', 'orders'],
'include_associations' => true,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect(count($service->lexicalCalls))->toBe(3);
expect($service->lexicalCalls[2]['forcedCustomerNumbers'])->toBe([777]);
$types = array_map(static fn(array $row) => (string)$row['entity_type'], $result['results']);
expect($types)->toContain('orders');
expect($result['meta']['intent_parser']['status'])->toBe('ok');
});
it('prefers newer records when relevance scores are comparable', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
[
'entity_type' => 'bookings',
'entity_id' => '1',
'title' => 'Older booking',
'score' => 90,
'payload' => ['updated_at' => '2026-01-01 00:00:00'],
],
[
'entity_type' => 'bookings',
'entity_id' => '2',
'title' => 'Newer booking',
'score' => 89,
'payload' => ['updated_at' => '2026-03-10 12:00:00'],
],
['entity_type' => 'bookings', 'entity_id' => '3', 'title' => 'B3', 'score' => 85],
['entity_type' => 'bookings', 'entity_id' => '4', 'title' => 'B4', 'score' => 84],
['entity_type' => 'bookings', 'entity_id' => '5', 'title' => 'B5', 'score' => 83],
]]);
$result = $service->search([
'query' => 'booking',
'allowed_types' => ['bookings'],
'include_associations' => false,
]);
expect($parser->calls)->toBe(0);
expect($result['results'][0]['entity_id'])->toBe('2');
});
it('keeps explicit identifier matches ahead of newer but weaker records', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
[
'entity_type' => 'orders',
'entity_id' => '100',
'title' => 'Exact order',
'score' => 90,
'payload' => ['updated_at' => '2026-01-01 00:00:00'],
],
[
'entity_type' => 'orders',
'entity_id' => '101',
'title' => 'Newer but weaker',
'score' => 89,
'payload' => ['updated_at' => '2026-03-12 00:00:00'],
],
['entity_type' => 'orders', 'entity_id' => '102', 'title' => 'O102', 'score' => 85],
['entity_type' => 'orders', 'entity_id' => '103', 'title' => 'O103', 'score' => 84],
['entity_type' => 'orders', 'entity_id' => '104', 'title' => 'O104', 'score' => 83],
]]);
$result = $service->search([
'query' => '12345',
'allowed_types' => ['orders'],
'include_associations' => false,
]);
expect($parser->calls)->toBe(0);
expect($result['results'][0]['entity_id'])->toBe('100');
});
it('promotes invoices orders order bookings and customers in ranking', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
['entity_type' => 'vehicles', 'entity_id' => '800', 'title' => 'Vehicle #800', 'score' => 97],
['entity_type' => 'departments', 'entity_id' => '801', 'title' => 'Department #801', 'score' => 96],
['entity_type' => 'invoices', 'entity_id' => '802', 'title' => 'Invoice #802', 'score' => 70],
['entity_type' => 'orders', 'entity_id' => '803', 'title' => 'Order #803', 'score' => 69],
['entity_type' => 'order_bookings', 'entity_id' => '804', 'title' => 'Order booking #804', 'score' => 68],
['entity_type' => 'customers', 'entity_id' => '805', 'title' => 'Customer #805', 'score' => 67],
]]);
$result = $service->search([
'query' => '12345',
'allowed_types' => ['vehicles', 'departments', 'invoices', 'orders', 'order_bookings', 'customers'],
'include_associations' => false,
]);
$types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']);
expect($parser->calls)->toBe(0);
expect(array_slice($types, 0, 4))->toBe([
'invoices',
'orders',
'order_bookings',
'customers',
]);
});
it('never prioritizes cancelled bookings over active bookings', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
[
'entity_type' => 'bookings',
'entity_id' => '500',
'title' => 'Cancelled booking',
'score' => 99,
'payload' => [
'updated_at' => '2026-03-12 12:00:00',
'status' => 'cancelled',
],
],
[
'entity_type' => 'bookings',
'entity_id' => '501',
'title' => 'Active booking',
'score' => 80,
'payload' => [
'updated_at' => '2026-03-11 12:00:00',
'status' => 'active',
],
],
['entity_type' => 'bookings', 'entity_id' => '502', 'title' => 'B502', 'score' => 79],
['entity_type' => 'bookings', 'entity_id' => '503', 'title' => 'B503', 'score' => 78],
['entity_type' => 'bookings', 'entity_id' => '504', 'title' => 'B504', 'score' => 77],
]]);
$result = $service->search([
'query' => 'booking',
'allowed_types' => ['bookings'],
'include_associations' => false,
]);
expect($parser->calls)->toBe(0);
expect($result['results'][0]['entity_id'])->toBe('501');
expect($result['results'][1]['entity_id'])->not->toBe('500');
});
it('heavily demotes configured low-priority entity types in ranking', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
[
'entity_type' => 'xlvask_customers',
'entity_id' => '700',
'title' => 'XLVask customer',
'score' => 99,
'payload' => ['updated_at' => '2026-03-12 10:00:00'],
],
[
'entity_type' => 'xlvask_usage_logs',
'entity_id' => '701',
'title' => 'XLVask usage log',
'score' => 98,
'payload' => ['updated_at' => '2026-03-12 11:00:00'],
],
[
'entity_type' => 'customer_discounts',
'entity_id' => '702',
'title' => 'Customer discount',
'score' => 97,
'payload' => ['updated_at' => '2026-03-12 12:00:00'],
],
[
'entity_type' => 'department_selfserve_vehicle_conditions',
'entity_id' => '703',
'title' => 'Vehicle condition',
'score' => 96,
'payload' => ['updated_at' => '2026-03-12 13:00:00'],
],
[
'entity_type' => 'permissions',
'entity_id' => '704',
'title' => 'Permission #704',
'score' => 95,
'payload' => ['updated_at' => '2026-03-12 14:00:00'],
],
[
'entity_type' => 'branding',
'entity_id' => '705',
'title' => 'Branding #705',
'score' => 94,
'payload' => ['updated_at' => '2026-03-12 15:00:00'],
],
[
'entity_type' => 'order_items',
'entity_id' => '706',
'title' => 'Order item #706',
'score' => 93,
'payload' => ['updated_at' => '2026-03-12 16:00:00'],
],
[
'entity_type' => 'module_config',
'entity_id' => '707',
'title' => 'Module config #707',
'score' => 92,
'payload' => ['updated_at' => '2026-03-12 17:00:00'],
],
[
'entity_type' => 'xlvask_vehicle_types',
'entity_id' => '710',
'title' => 'XLVask vehicle type',
'score' => 91,
'payload' => ['updated_at' => '2026-03-12 18:00:00'],
],
[
'entity_type' => 'motorapi_lookups',
'entity_id' => '711',
'title' => 'MotorAPI lookup',
'score' => 90,
'payload' => ['updated_at' => '2026-03-12 19:00:00'],
],
[
'entity_type' => 'orders',
'entity_id' => '708',
'title' => 'Order #708',
'score' => 76,
'payload' => ['updated_at' => '2026-03-01 00:00:00'],
],
[
'entity_type' => 'customers',
'entity_id' => '709',
'title' => 'Customer #709',
'score' => 74,
'payload' => ['updated_at' => '2026-03-01 00:00:00'],
],
]]);
$result = $service->search([
'query' => '12345',
'allowed_types' => [
'orders',
'customers',
'xlvask_customers',
'xlvask_usage_logs',
'customer_discounts',
'department_selfserve_vehicle_conditions',
'permissions',
'branding',
'order_items',
'module_config',
'xlvask_vehicle_types',
'motorapi_lookups',
],
'include_associations' => false,
]);
$types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']);
expect($parser->calls)->toBe(0);
expect($types[0])->toBe('orders');
expect($types[1])->toBe('customers');
expect(array_slice($types, 2, 10))->toBe([
'xlvask_customers',
'xlvask_usage_logs',
'customer_discounts',
'department_selfserve_vehicle_conditions',
'permissions',
'branding',
'order_items',
'module_config',
'xlvask_vehicle_types',
'motorapi_lookups',
]);
});
it('tokenizes unicode names without stripping non ascii letters', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$tokens = system_search_service_invoke_private($service, 'tokenize', ['Møller Århus']);
expect($tokens)->toContain('møller');
expect($tokens)->toContain('århus');
expect($tokens)->not->toContain('ller');
expect($tokens)->not->toContain('rhus');
});
it('does not treat explicit identifier queries as intent driven', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$looksIntentDriven = system_search_service_invoke_private(
$service,
'queryLooksIntentDriven',
['order 123456 for acme', ['order', '123456', 'for', 'acme']]
);
expect($looksIntentDriven)->toBeFalse();
});
it('requires broader term coverage for multi word scoring', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$narrowScore = system_search_service_invoke_private(
$service,
'scoreRow',
[['title' => 'Acme Corp', 'description' => ''], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
);
$broadScore = system_search_service_invoke_private(
$service,
'scoreRow',
[['title' => 'Acme Corp', 'description' => 'Overdue invoice'], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
);
expect($narrowScore)->toBe(0);
expect($broadScore)->toBeGreaterThan(0);
});
it('falls back to invoice date ranges when invoice names are missing', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$fullRangeTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
[null, '2026-02-01 00:00:01', '2026-02-28 23:59:59', 42]
);
$fromOnlyTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
['', '2026-02-01 00:00:01', null, 43]
);
$fallbackTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
[null, null, null, 44]
);
expect($fullRangeTitle)->toBe('2026-02-01 - 2026-02-28');
expect($fromOnlyTitle)->toBe('2026-02-01');
expect($fallbackTitle)->toBe('Invoice collection #44');
});
it('uses the goal criteria label for department goal titles', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$labeledTitle = system_search_service_invoke_private(
$service,
'departmentGoalResultTitle',
[json_encode(['label' => 'Weekly Wash Goal'], JSON_UNESCAPED_UNICODE), 91, 'Department goals #91']
);
$fallbackTitle = system_search_service_invoke_private(
$service,
'departmentGoalResultTitle',
[json_encode(['target' => 12], JSON_UNESCAPED_UNICODE), 92, 'Department goals #92']
);
expect($labeledTitle)->toBe('Weekly Wash Goal');
expect($fallbackTitle)->toBe('Department goals #92');
});
it('derives xlvask customer numbers only from digits-only extern ids', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$digitsOnly = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345679', 'digits_only']);
$uuidLike = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['09ed15d4-5a12-4d23-beac-4065174a74eb', 'digits_only']);
$mixed = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345-A', 'digits_only']);
$blank = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', [' ', 'digits_only']);
expect($digitsOnly)->toBe(12345679);
expect($uuidLike)->toBeNull();
expect($mixed)->toBeNull();
expect($blank)->toBeNull();
});
it('replaces unnamed user titles with the customer context name', function (): void {
$service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$service->customerContexts = [
777 => [
'customer_number' => 777,
'name' => 'Acme Transport',
'barred' => false,
'status' => 'active',
],
];
$result = system_search_service_invoke_private($service, 'decorateSearchResultWithCustomerContext', [[
'entity_type' => 'users',
'entity_id' => '55',
'title' => 'unnamed',
'description' => '',
'customer_number' => 777,
'payload' => [
'id' => 55,
'display_name' => 'unnamed',
],
]]);
expect($result['title'])->toBe('Acme Transport');
expect($result['customer_name'])->toBe('Acme Transport');
});
it('enriches object attachment results with associated customer context', function (): void {
$service = new CustomerContextAwareTestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$service->customerContexts = [
777 => [
'customer_number' => 777,
'user_id' => 55,
'name' => 'Acme Transport',
'barred' => true,
'status' => 'barred',
'email' => 'dispatch@acme.test',
'phone' => '40112233',
'cvr' => '12345678',
'address' => 'Road 1',
'city' => 'Aarhus',
'zip' => '8000',
],
];
$result = system_search_service_invoke_private($service, 'buildObjectSearchResult', [[
'id' => 88,
'object_type' => 'orders',
'object_id' => 501,
'content' => json_encode(['other' => 'wash_certificate.pdf'], JSON_UNESCAPED_UNICODE),
'customer_number' => 777,
'department_id' => 12,
'order_reference' => 'REF-501',
'customer_name' => 'Acme Transport',
'updated_at' => '2026-03-11 12:00:00',
'created_at' => '2026-03-10 12:00:00',
], ['acme', '777'], 9]);
expect($result['entity_type'])->toBe('objects');
expect($result['customer_number'])->toBe(777);
expect($result['customer_name'])->toBe('Acme Transport');
expect($result['customer_barred'])->toBeTrue();
expect($result['customer_status'])->toBe('barred');
expect($result['description'])->toBe('REF-501 / Acme Transport');
expect($result['payload']['linked_entity_type'])->toBe('orders');
expect($result['payload']['order_reference'])->toBe('REF-501');
expect($result['payload']['customer_context']['cvr'])->toBe('12345678');
});
it('includes the economic customer index in cache dependencies for customer scoped results', function (): void {
$service = new TestableSystemSearchService(new FakeSystemSearchIntentParser(), []);
$tables = system_search_service_invoke_private($service, 'relevantSourceTables', [['objects', 'orders', 'vehicles']]);
expect($tables)->toContain(system_search_economic_customer_index::TABLE);
});
@@ -0,0 +1,658 @@
<?php
app_require('classes/system_search_cache.php');
app_require('classes/system_search_document_index.php');
app_require('classes/system_search_economic_customer_index.php');
app_require('classes/system_search_registry.php');
app_require('classes/system_search_service.php');
use classes\system_search_cache;
use classes\system_search_economic_customer_index;
use classes\system_search_service;
if (!class_exists('SystemSearchTestRedisAdapter')) {
class SystemSearchTestRedisAdapter
{
private array $store = [];
public function get(string $key): mixed
{
return $this->store[$key] ?? null;
}
public function set(string $key, string $value): void
{
$this->store[$key] = $value;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function delete(string $key): void
{
unset($this->store[$key]);
}
public function expire(string $key, int $ttl): void
{
// TTL is not simulated in unit tests.
}
public function set_array(string $key, array $value): void
{
$this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE);
}
public function get_array(string $key): ?array
{
$value = $this->store[$key] ?? null;
if (!is_string($value)) {
return null;
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key)) {
unset($this->store[$key]);
}
}
}
}
}
if (!class_exists('TestableSystemSearchService')) {
class TestableSystemSearchService extends system_search_service
{
/**
* @var array<int, array<string, mixed>>
*/
public array $lexicalCalls = [];
/**
* @var array<int, array<int, array<string, mixed>>>
*/
private array $queuedLexicalResults;
public function __construct(array $queuedLexicalResults)
{
$this->queuedLexicalResults = $queuedLexicalResults;
parent::__construct();
}
protected function executeLexicalSearch(
array $activeTypes,
array $terms,
array $entityBoost,
array $ownOnlyTypes,
?int $ownCustomerNumber,
array $permissionsCatalogAll,
array $permissionsCatalogOwn,
array $moduleConfigVisibility,
array $allowedDepartmentIds = [],
array $forcedCustomerNumbers = []
): array {
$this->lexicalCalls[] = [
'activeTypes' => $activeTypes,
'terms' => $terms,
'entityBoost' => $entityBoost,
'ownOnlyTypes' => $ownOnlyTypes,
'ownCustomerNumber' => $ownCustomerNumber,
'allowedDepartmentIds' => $allowedDepartmentIds,
'forcedCustomerNumbers' => $forcedCustomerNumbers,
];
if (empty($this->queuedLexicalResults)) {
return [];
}
return array_shift($this->queuedLexicalResults);
}
}
}
if (!class_exists('CustomerContextAwareTestableSystemSearchService')) {
class CustomerContextAwareTestableSystemSearchService extends TestableSystemSearchService
{
/**
* @var array<int, array<string, mixed>>
*/
public array $customerContexts = [];
protected function loadCustomerContexts(array $customerNumbers): array
{
$contexts = [];
foreach ($customerNumbers as $customerNumber) {
$normalized = (int)$customerNumber;
if ($normalized <= 0 || !isset($this->customerContexts[$normalized])) {
continue;
}
$contexts[$normalized] = $this->customerContexts[$normalized];
}
return $contexts;
}
}
}
if (!function_exists('system_search_service_invoke_private')) {
function system_search_service_invoke_private(object $instance, string $method, array $args = []): mixed
{
$reflection = new ReflectionMethod($instance, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($instance, $args);
}
}
beforeEach(function (): void {
system_search_cache::setAdapterForTests(null);
});
it('returns a single capped result set without pagination or intent metadata', function (): void {
$rows = [];
for ($i = 1; $i <= 55; $i++) {
$rows[] = ['entity_type' => 'orders', 'entity_id' => (string)$i, 'title' => 'Order #' . $i, 'score' => 100 - ($i % 10)];
}
$service = new TestableSystemSearchService([$rows]);
$result = $service->search([
'query' => 'order',
'allowed_types' => ['orders'],
'max_results' => 500,
'include_associations' => false,
]);
expect(count($result['results']))->toBe(50);
expect($result['meta']['max_results'])->toBe(50);
expect($result['meta']['returned'])->toBe(50);
expect($result['meta']['truncated'])->toBeTrue();
expect($result['meta'])->not->toHaveKey('limit');
expect($result['meta'])->not->toHaveKey('offset');
expect($result['meta'])->not->toHaveKey('intent_parser');
});
it('honors smaller max_results values for one-shot search responses', function (): void {
$service = new TestableSystemSearchService([[
['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 100],
['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'Order #2', 'score' => 99],
['entity_type' => 'orders', 'entity_id' => '3', 'title' => 'Order #3', 'score' => 98],
['entity_type' => 'orders', 'entity_id' => '4', 'title' => 'Order #4', 'score' => 97],
]]);
$result = $service->search([
'query' => 'order',
'allowed_types' => ['orders'],
'max_results' => 3,
'include_associations' => false,
]);
expect(array_column($result['results'], 'entity_id'))->toBe(['1', '2', '3']);
expect($result['meta']['returned'])->toBe(3);
expect($result['meta']['truncated'])->toBeTrue();
});
it('uses default search types unless the query names a lower priority entity type', function (): void {
$service = new TestableSystemSearchService([[]]);
$service->search([
'query' => 'acme',
'allowed_types' => ['customers', 'orders', 'module_config', 'permissions'],
'include_associations' => false,
]);
expect($service->lexicalCalls[0]['activeTypes'])->toContain('customers');
expect($service->lexicalCalls[0]['activeTypes'])->toContain('orders');
expect($service->lexicalCalls[0]['activeTypes'])->not->toContain('module_config');
expect($service->lexicalCalls[0]['activeTypes'])->not->toContain('permissions');
$service = new TestableSystemSearchService([[]]);
$service->search([
'query' => 'stripe module config',
'allowed_types' => ['customers', 'orders', 'module_config', 'permissions'],
'include_associations' => false,
]);
expect($service->lexicalCalls[0]['activeTypes'])->toContain('module_config');
});
it('lets explicit include type filters search and return focused lower-score matches', function (): void {
$service = new TestableSystemSearchService([[
['entity_type' => 'module_config', 'entity_id' => 'Stripe:enabled', 'title' => 'Stripe.enabled', 'score' => 30],
['entity_type' => 'module_config', 'entity_id' => 'Stripe:key', 'title' => 'Stripe.api_key', 'score' => 10],
]]);
$result = $service->search([
'query' => 'stripe',
'include_types' => ['module_config'],
'allowed_types' => ['module_config'],
'include_associations' => false,
]);
expect(array_column($result['results'], 'entity_id'))->toBe(['Stripe:enabled']);
});
it('drops weak non-explicit matches after relevance penalties', function (): void {
$service = new TestableSystemSearchService([[
['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 30],
['entity_type' => 'module_config', 'entity_id' => 'openAI:enabled', 'title' => 'openAI.enabled', 'score' => 100],
['entity_type' => 'vehicles', 'entity_id' => '3', 'title' => 'Vehicle #3', 'score' => 5],
]]);
$result = $service->search([
'query' => 'acme',
'allowed_types' => ['orders', 'module_config', 'vehicles'],
'include_associations' => false,
]);
expect(array_column($result['results'], 'entity_id'))->toBe(['1']);
});
it('scopes query cache by permission context and result cap', function (): void {
system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter());
$service = new TestableSystemSearchService([
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'first', 'score' => 95]],
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'second', 'score' => 96]],
]);
$first = $service->search([
'query' => 'acme',
'allowed_types' => ['orders'],
'include_associations' => false,
'max_results' => 1,
'permissions_catalog_own' => ['list_orders'],
]);
$cached = $service->search([
'query' => 'acme',
'allowed_types' => ['orders'],
'include_associations' => false,
'max_results' => 1,
'permissions_catalog_own' => ['list_orders'],
]);
$secondCap = $service->search([
'query' => 'acme',
'allowed_types' => ['orders'],
'include_associations' => false,
'max_results' => 2,
'permissions_catalog_own' => ['list_orders'],
]);
expect(count($service->lexicalCalls))->toBe(2);
expect($first['results'][0]['entity_id'])->toBe('1');
expect($cached['results'][0]['entity_id'])->toBe('1');
expect($cached['meta']['cache']['hit'])->toBeTrue();
expect($secondCap['results'][0]['entity_id'])->toBe('2');
});
it('only expands associations from strong customer matches', function (): void {
$strong = new TestableSystemSearchService([
[[
'entity_type' => 'customers',
'entity_id' => '10',
'title' => 'Acme',
'customer_number' => 777,
'score' => 90,
]],
[[
'entity_type' => 'orders',
'entity_id' => '501',
'title' => 'Order #501',
'customer_number' => 777,
'score' => 95,
]],
]);
$strongResult = $strong->search([
'query' => 'acme',
'allowed_types' => ['customers', 'orders'],
'include_associations' => true,
]);
expect(count($strong->lexicalCalls))->toBe(2);
expect($strong->lexicalCalls[1]['forcedCustomerNumbers'])->toBe([777]);
expect(array_column($strongResult['results'], 'entity_type'))->toContain('orders');
$weak = new TestableSystemSearchService([
[[
'entity_type' => 'customers',
'entity_id' => '10',
'title' => 'Acme',
'customer_number' => 777,
'score' => 20,
]],
[[
'entity_type' => 'orders',
'entity_id' => '501',
'title' => 'Order #501',
'customer_number' => 777,
'score' => 95,
]],
]);
$weak->search([
'query' => 'acme',
'allowed_types' => ['customers', 'orders'],
'include_associations' => true,
]);
expect(count($weak->lexicalCalls))->toBe(1);
});
it('passes allowed department ids into lexical execution context', function (): void {
$service = new TestableSystemSearchService([[
['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 70],
]]);
$service->search([
'query' => 'order',
'allowed_types' => ['orders'],
'include_associations' => false,
'allowed_department_ids' => [3, '7', 3],
]);
expect(count($service->lexicalCalls))->toBe(1);
expect($service->lexicalCalls[0]['allowedDepartmentIds'])->toBe([3, 7]);
});
it('only applies indexed department filters to department-scoped indexed entities', function (): void {
$service = new TestableSystemSearchService([]);
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['orders']))->toBeTrue();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['objects']))->toBeTrue();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['bookings']))->toBeTrue();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['customers']))->toBeFalse();
expect(system_search_service_invoke_private($service, 'indexedEntitySupportsDepartmentFilter', ['invoices']))->toBeFalse();
});
it('expands danish discount wording into lexical discount synonyms', function (): void {
$service = new TestableSystemSearchService([[
['entity_type' => 'customer_discounts', 'entity_id' => '1', 'title' => 'd1', 'score' => 95],
]]);
$service->search([
'query' => 'pleno rabat',
'allowed_types' => ['customer_discounts'],
'include_types' => ['customer_discounts'],
'include_associations' => false,
]);
$terms = $service->lexicalCalls[0]['terms'] ?? [];
expect($terms)->toContain('rabat');
expect($terms)->toContain('discount');
});
it('prefers newer records when relevance scores are comparable', function (): void {
$service = new TestableSystemSearchService([[
[
'entity_type' => 'bookings',
'entity_id' => '1',
'title' => 'Older booking',
'score' => 90,
'payload' => ['updated_at' => '2026-01-01 00:00:00'],
],
[
'entity_type' => 'bookings',
'entity_id' => '2',
'title' => 'Newer booking',
'score' => 89,
'payload' => ['updated_at' => '2026-03-10 12:00:00'],
],
]]);
$result = $service->search([
'query' => 'booking',
'allowed_types' => ['bookings'],
'include_associations' => false,
]);
expect($result['results'][0]['entity_id'])->toBe('2');
});
it('keeps explicit identifier matches ahead of newer but weaker records', function (): void {
$service = new TestableSystemSearchService([[
[
'entity_type' => 'orders',
'entity_id' => '100',
'title' => 'Exact order',
'score' => 90,
'payload' => ['updated_at' => '2026-01-01 00:00:00'],
],
[
'entity_type' => 'orders',
'entity_id' => '101',
'title' => 'Newer but weaker',
'score' => 89,
'payload' => ['updated_at' => '2026-03-12 00:00:00'],
],
]]);
$result = $service->search([
'query' => '12345',
'allowed_types' => ['orders'],
'include_associations' => false,
]);
expect($result['results'][0]['entity_id'])->toBe('100');
});
it('promotes invoices orders order bookings and customers in ranking', function (): void {
$service = new TestableSystemSearchService([[
['entity_type' => 'vehicles', 'entity_id' => '800', 'title' => 'Vehicle #800', 'score' => 97],
['entity_type' => 'departments', 'entity_id' => '801', 'title' => 'Department #801', 'score' => 96],
['entity_type' => 'invoices', 'entity_id' => '802', 'title' => 'Invoice #802', 'score' => 70],
['entity_type' => 'orders', 'entity_id' => '803', 'title' => 'Order #803', 'score' => 69],
['entity_type' => 'order_bookings', 'entity_id' => '804', 'title' => 'Order booking #804', 'score' => 68],
['entity_type' => 'customers', 'entity_id' => '805', 'title' => 'Customer #805', 'score' => 67],
]]);
$result = $service->search([
'query' => '12345',
'allowed_types' => ['vehicles', 'departments', 'invoices', 'orders', 'order_bookings', 'customers'],
'include_associations' => false,
]);
$types = array_map(static fn(array $row): string => (string)$row['entity_type'], $result['results']);
expect(array_slice($types, 0, 4))->toBe([
'invoices',
'orders',
'order_bookings',
'customers',
]);
});
it('never prioritizes cancelled bookings over active bookings', function (): void {
$service = new TestableSystemSearchService([[
[
'entity_type' => 'bookings',
'entity_id' => '500',
'title' => 'Cancelled booking',
'score' => 99,
'payload' => [
'updated_at' => '2026-03-12 12:00:00',
'status' => 'cancelled',
],
],
[
'entity_type' => 'bookings',
'entity_id' => '501',
'title' => 'Active booking',
'score' => 80,
'payload' => [
'updated_at' => '2026-03-11 12:00:00',
'status' => 'active',
],
],
]]);
$result = $service->search([
'query' => 'booking',
'allowed_types' => ['bookings'],
'include_associations' => false,
]);
expect($result['results'][0]['entity_id'])->toBe('501');
});
it('tokenizes unicode names without stripping non ascii letters', function (): void {
$service = new TestableSystemSearchService([]);
$tokens = system_search_service_invoke_private($service, 'tokenize', ['Møller Århus']);
expect($tokens)->toContain('møller');
expect($tokens)->toContain('århus');
expect($tokens)->not->toContain('ller');
expect($tokens)->not->toContain('rhus');
});
it('requires broader term coverage for multi word scoring', function (): void {
$service = new TestableSystemSearchService([]);
$narrowScore = system_search_service_invoke_private(
$service,
'scoreRow',
[['title' => 'Acme Corp', 'description' => ''], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
);
$broadScore = system_search_service_invoke_private(
$service,
'scoreRow',
[['title' => 'Acme Corp', 'description' => 'Overdue invoice'], ['title' => 4, 'description' => 2], ['acme', 'overdue', 'invoice']]
);
expect($narrowScore)->toBe(0);
expect($broadScore)->toBeGreaterThan(0);
});
it('falls back to invoice date ranges when invoice names are missing', function (): void {
$service = new TestableSystemSearchService([]);
$fullRangeTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
[null, '2026-02-01 00:00:01', '2026-02-28 23:59:59', 42]
);
$fromOnlyTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
['', '2026-02-01 00:00:01', null, 43]
);
$fallbackTitle = system_search_service_invoke_private(
$service,
'invoiceResultTitle',
[null, null, null, 44]
);
expect($fullRangeTitle)->toBe('2026-02-01 - 2026-02-28');
expect($fromOnlyTitle)->toBe('2026-02-01');
expect($fallbackTitle)->toBe('Invoice collection #44');
});
it('uses the goal criteria label for department goal titles', function (): void {
$service = new TestableSystemSearchService([]);
$labeledTitle = system_search_service_invoke_private(
$service,
'departmentGoalResultTitle',
[json_encode(['label' => 'Weekly Wash Goal'], JSON_UNESCAPED_UNICODE), 91, 'Department goals #91']
);
$fallbackTitle = system_search_service_invoke_private(
$service,
'departmentGoalResultTitle',
[json_encode(['target' => 12], JSON_UNESCAPED_UNICODE), 92, 'Department goals #92']
);
expect($labeledTitle)->toBe('Weekly Wash Goal');
expect($fallbackTitle)->toBe('Department goals #92');
});
it('derives xlvask customer numbers only from digits-only extern ids', function (): void {
$service = new TestableSystemSearchService([]);
$digitsOnly = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345679', 'digits_only']);
$uuidLike = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['09ed15d4-5a12-4d23-beac-4065174a74eb', 'digits_only']);
$mixed = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', ['12345-A', 'digits_only']);
$blank = system_search_service_invoke_private($service, 'resolveConfiguredCustomerNumber', [' ', 'digits_only']);
expect($digitsOnly)->toBe(12345679);
expect($uuidLike)->toBeNull();
expect($mixed)->toBeNull();
expect($blank)->toBeNull();
});
it('replaces unnamed user titles with the customer context name', function (): void {
$service = new CustomerContextAwareTestableSystemSearchService([]);
$service->customerContexts = [
777 => [
'customer_number' => 777,
'name' => 'Acme Transport',
'barred' => false,
'status' => 'active',
],
];
$result = system_search_service_invoke_private($service, 'decorateSearchResultWithCustomerContext', [[
'entity_type' => 'users',
'entity_id' => '55',
'title' => 'unnamed',
'description' => '',
'customer_number' => 777,
'payload' => [
'id' => 55,
'display_name' => 'unnamed',
],
]]);
expect($result['title'])->toBe('Acme Transport');
expect($result['customer_name'])->toBe('Acme Transport');
});
it('enriches object attachment results with associated customer context', function (): void {
$service = new CustomerContextAwareTestableSystemSearchService([]);
$service->customerContexts = [
777 => [
'customer_number' => 777,
'user_id' => 55,
'name' => 'Acme Transport',
'barred' => true,
'status' => 'barred',
'email' => 'dispatch@acme.test',
'phone' => '40112233',
'cvr' => '12345678',
'address' => 'Road 1',
'city' => 'Aarhus',
'zip' => '8000',
],
];
$result = system_search_service_invoke_private($service, 'buildObjectSearchResult', [[
'id' => 88,
'object_type' => 'orders',
'object_id' => 501,
'content' => json_encode(['other' => 'wash_certificate.pdf'], JSON_UNESCAPED_UNICODE),
'customer_number' => 777,
'department_id' => 12,
'order_reference' => 'REF-501',
'customer_name' => 'Acme Transport',
'updated_at' => '2026-03-11 12:00:00',
'created_at' => '2026-03-10 12:00:00',
], ['acme', '777'], 9]);
expect($result['entity_type'])->toBe('objects');
expect($result['customer_number'])->toBe(777);
expect($result['customer_name'])->toBe('Acme Transport');
expect($result['customer_barred'])->toBeTrue();
expect($result['customer_status'])->toBe('barred');
expect($result['description'])->toBe('REF-501 / Acme Transport');
expect($result['payload']['linked_entity_type'])->toBe('orders');
expect($result['payload']['order_reference'])->toBe('REF-501');
expect($result['payload']['customer_context']['cvr'])->toBe('12345678');
});
it('includes the economic customer index in cache dependencies for customer scoped results', function (): void {
$service = new TestableSystemSearchService([]);
$tables = system_search_service_invoke_private($service, 'relevantSourceTables', [['objects', 'orders', 'vehicles']]);
expect($tables)->toContain(system_search_economic_customer_index::TABLE);
});
@@ -104,6 +104,25 @@ it('resolves relay bindings without requiring a primary department gateway first
->and($body)->toContain('heartbeatTimestamp');
});
it('reactivates soft-deleted relay bindings before inserting replacements', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
expect($managerSource)->not->toBeFalse();
preg_match(
'/public function setRelayBindings\(int \$gatewayId, array \$bindings, \?int \$userId = null\): array\s*\{(?P<body>.*?)\n \}\n\n \/\*\*/s',
(string)$managerSource,
$matches
);
expect($matches)->toHaveKey('body');
$body = (string)$matches['body'];
expect($body)->toContain("'gateway_id' => \$gatewayId")
->and($body)->toContain("'relay_id' => \$relayId")
->and($body)->toContain('$bindingObject->deleted_at->set(null);')
->and($body)->not->toContain("'deleted_at' => null,\n ], ['id']);");
});
it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void {
$reflection = new ReflectionClass(edge_gateway_manager::class);
$operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class);
@@ -130,6 +130,21 @@ it('adds wash_started_at column for legacy selfserve wash session schemas', func
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at');
});
it('adds reporting indexes for completed self-serve wash session counts', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
$apiSchemaContent = file_get_contents(app_path('tests/Support/Api/ApiSchemaBootstrap.php'));
expect($bootstrapContent)->not->toBeFalse()
->and($apiSchemaContent)->not->toBeFalse();
expect($bootstrapContent)->toContain('idx_selfserve_wash_sessions_department_completed')
->and($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)')
->and($bootstrapContent)->toContain('idx_selfserve_wash_sessions_order')
->and($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)')
->and($bootstrapContent)->toContain('public static function ensureIndex');
expect($apiSchemaContent)->toContain('idx_selfserve_wash_sessions_department_completed')
->and($apiSchemaContent)->toContain('idx_selfserve_wash_sessions_order');
});
it('adds lane-level self-serve enablement for existing department lanes', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
@@ -0,0 +1,46 @@
<?php
app_require('classes/subuser_permission_templates_service.php');
app_require('modules/subusers/helpers/subusers_permission_node_key.php');
use classes\subuser_permission_templates_service;
use modules\subusers\helpers\subusers_permission_node_key;
it('exposes driver access templates with valid subuser permission node keys', function (): void {
$service = new subuser_permission_templates_service();
$templates = $service->templates();
expect(array_column($templates, 'key'))
->toContain(subuser_permission_templates_service::TEMPLATE_DEACTIVATED)
->toContain(subuser_permission_templates_service::TEMPLATE_DRIVER)
->toContain(subuser_permission_templates_service::TEMPLATE_BOOKING_COORDINATOR)
->toContain(subuser_permission_templates_service::TEMPLATE_FLEET_ADMIN);
foreach ($templates as $template) {
foreach ($template['permissions'] as $permission) {
expect(subusers_permission_node_key::tryFrom($permission))->not->toBeNull();
}
}
});
it('expands and classifies practical driver access templates', function (): void {
$service = new subuser_permission_templates_service();
$driver = $service->expandTemplate(subuser_permission_templates_service::TEMPLATE_DRIVER);
$deactivated = $service->expandTemplate(subuser_permission_templates_service::TEMPLATE_DEACTIVATED);
expect($driver['enabled'])->toBeTrue()
->and($driver['permissions'])
->toContain('VEHICLES_LIST')
->toContain('BOOKINGS_ADD')
->and($service->classify($driver['permissions'], true))
->toBe(subuser_permission_templates_service::TEMPLATE_DRIVER)
->and($deactivated['enabled'])
->toBeFalse()
->and($deactivated['permissions'])
->toBe([])
->and($service->classify(['VEHICLES_LIST'], false))
->toBe(subuser_permission_templates_service::TEMPLATE_DEACTIVATED)
->and($service->classify(['VEHICLES_LIST'], true))
->toBe(subuser_permission_templates_service::TEMPLATE_CUSTOM);
});
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
it('fails CI API runs instead of allowing runtime bootstrap skips to pass', function (): void {
$runner = (string)file_get_contents(__DIR__ . '/../../Support/run_ci_suite.php');
expect($runner)->toContain('API_TEST_FAIL_ON_SKIP=1')
->and($runner)->toContain("assert_required_php_extensions(['mysqli'])");
});
@@ -563,5 +563,5 @@ it('wires department weather route to use batched wash aggregation', function ()
expect($routeContent)->toContain('loadDepartmentWashCountsBySlot');
expect($routeContent)->toContain('countWashesByHourForDepartments');
expect($ordersContent)->toContain('public function countWashesByHourForDepartments');
expect($ordersContent)->toContain('GROUP BY o.department_id');
expect($ordersContent)->toContain('department_wash_count_service');
});
@@ -24,6 +24,38 @@ it('builds stable XL Vask automation item signatures', function (): void {
]);
});
it('identifies strict price agreement matches by product, quantity, and total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
['product_id' => 21, 'quantity' => 1, 'price' => 79],
];
$orderItems = [
['product_id' => 21, 'quantity' => 1, 'price' => 79],
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeTrue();
});
it('rejects price agreement automation when product lines differ despite equal total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 50, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::itemsTotalForAutomation($usageItems))
->toBe(xlvask_automation_service::itemsTotalForAutomation($orderItems))
->and(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeFalse();
});
it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void {
$row = xlvask_automation_service::normalizeUsageLogRowForAutomation([
'id' => 47086,
@@ -107,6 +139,23 @@ it('declares cached amount summary columns for XL Vask usage logs', function ():
->toContain('cached_amount_at');
});
it('keeps automatic XL Vask execution scoped to exact attachments', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('&& $this->isExactAttachSuggestionForContext($suggestion, $context)')
->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;")
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
});
it('does not automatically create XL Vask orders', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('if ($action === self::ACTION_CREATE) {')
->toContain('return false;');
});
it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
+3 -2
View File
@@ -55,6 +55,7 @@ trait route_t
} catch (Exception $e) {
$response->error($e->getMessage(), 400);
}
return false;
}
/**
@@ -486,7 +487,7 @@ trait route_t
* @param string|permission_node $permission
* @return bool
*/
public function hasPermission(string|permission_node $permission, int $customer_number = null): bool
public function hasPermission(string|permission_node $permission, ?int $customer_number = null): bool
{
return $this->evaluatePermission($permission, $customer_number, false);
}
@@ -611,7 +612,7 @@ trait route_t
* @param string|null $parameter The name of the parameter
* @return void
*/
public function requireParameterIntPositive(int $value, string $parameter = null): void
public function requireParameterIntPositive(int $value, ?string $parameter = null): void
{
global $response;
if ($value <= 0) {