Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d2e906763 | ||
|
|
a7181a4ab2 | ||
|
|
fc6c76ad1b | ||
|
|
6a694f92cc | ||
|
|
b7a2dc04d7 | ||
|
|
870b88e707 | ||
|
|
e14cddc1fb | ||
|
|
31887fa8c9 | ||
|
|
eac83b18a0 | ||
|
|
3817a37021 | ||
|
|
940a3e5e9b | ||
|
|
3221223865 | ||
|
|
b51006d9d1 | ||
|
|
6b7592921d | ||
|
|
f26a427510 | ||
|
|
6de747252f | ||
|
|
ff225ff5e7 | ||
|
|
dcef993f12 | ||
|
|
23aca449f7 | ||
|
|
31b5ba136a | ||
|
|
f0b5479f30 | ||
|
|
b77efc538a | ||
|
|
10d1eb5bac | ||
|
|
084435e9b8 | ||
|
|
172a21c517 | ||
|
|
c24428e4c7 | ||
|
|
bf1d6a583e | ||
|
|
08ac16e665 | ||
|
|
79185a3c76 | ||
|
|
3a730e3507 | ||
|
|
ce43c4e064 | ||
|
|
579ddcf510 | ||
|
|
0b342a7780 | ||
|
|
57bcbaf72a | ||
|
|
d9fbba3130 | ||
|
|
e4465d9d91 | ||
|
|
734cd13c87 | ||
|
|
d0f94ac549 | ||
|
|
1d25cbe21c | ||
|
|
53d0636193 | ||
|
|
04bb26f1b0 | ||
|
|
df0d4783d0 | ||
|
|
39c06ceab6 | ||
|
|
7dd428d18e | ||
|
|
0103a40156 | ||
|
|
e208b1b2a4 | ||
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
ed2736e528 | ||
|
|
c7f5c73a9e |
+830
-74
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,27 +11,48 @@ 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';
|
||||
private const MANAGED_EMPLOYEE_CUSTOMER_NUMBER = 0;
|
||||
|
||||
/**
|
||||
* Permissions required for managed employees to sign in and appear in the employee login picker.
|
||||
* Permissions required for managed employees to sign in, appear in the employee login picker,
|
||||
* and open the department admin shell used by their scoped role permissions.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
|
||||
'admin',
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
|
||||
];
|
||||
|
||||
/**
|
||||
* 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>}>
|
||||
*/
|
||||
private const ROLE_PRESETS = [
|
||||
'viewer' => [
|
||||
'label' => 'Viewer',
|
||||
'description' => 'Can sign in and view assigned department data.',
|
||||
'label' => 'Deactivated',
|
||||
'description' => 'Keeps the employee registered without order, booking, or management permissions.',
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
@@ -39,18 +60,58 @@ class limited_backoffice_service
|
||||
],
|
||||
'cashier' => [
|
||||
'label' => 'Cashier',
|
||||
'description' => 'Can work with orders and order lines for assigned departments.',
|
||||
'description' => 'Can work with POS orders, products, customers, vehicles, attachments, payments, scanners, and bookings for assigned departments.',
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_departments',
|
||||
'list_orders',
|
||||
'fetch_order',
|
||||
'add_order',
|
||||
'edit_order',
|
||||
'mark_order_as_completed',
|
||||
'list_order_items',
|
||||
'add_order_items',
|
||||
'edit_order_items',
|
||||
'delete_order_items',
|
||||
'list_order_attachments',
|
||||
'add_order_attachments',
|
||||
'download_order_attachments',
|
||||
'list_products',
|
||||
'list_categories',
|
||||
'list_department_categories',
|
||||
'list_department_order_recommended',
|
||||
'vehicle_product_suggestions',
|
||||
'search_customers',
|
||||
'get_user_from_customer_number',
|
||||
'list_customer_notes',
|
||||
'add_customer_note',
|
||||
'list_customer_attributes',
|
||||
'search_vehicles',
|
||||
'view_vehicle_status',
|
||||
'list_unknown_customer_vehicles',
|
||||
'list_vehicle_customer_suggestions',
|
||||
'department_license_plate_lookup',
|
||||
'department_vehicle_order_last_five',
|
||||
'list_number_plate_scans',
|
||||
'list_department_number_plate_scanners',
|
||||
'charge_order',
|
||||
'get_payment_intent',
|
||||
'confirm_payment_intent',
|
||||
'modules_stripe_department_terminal_readers_list',
|
||||
'modules_stripe_invoice_send',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'add_bookings',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'list_department_daily_reports',
|
||||
'list_notifications',
|
||||
'list_own_notifications',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
],
|
||||
],
|
||||
'booking_coordinator' => [
|
||||
@@ -59,16 +120,23 @@ class limited_backoffice_service
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_departments',
|
||||
'list_orders',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'add_bookings',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'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' => [
|
||||
@@ -77,21 +145,53 @@ class limited_backoffice_service
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_departments',
|
||||
'list_orders',
|
||||
'fetch_order',
|
||||
'add_order',
|
||||
'edit_order',
|
||||
'delete_order',
|
||||
'mark_order_as_completed',
|
||||
'list_order_items',
|
||||
'add_order_items',
|
||||
'edit_order_items',
|
||||
'delete_order_items',
|
||||
'list_order_attachments',
|
||||
'add_order_attachments',
|
||||
'download_order_attachments',
|
||||
'list_products',
|
||||
'list_categories',
|
||||
'list_department_categories',
|
||||
'list_department_order_recommended',
|
||||
'vehicle_product_suggestions',
|
||||
'search_customers',
|
||||
'get_user_from_customer_number',
|
||||
'list_customer_notes',
|
||||
'add_customer_note',
|
||||
'list_customer_attributes',
|
||||
'search_vehicles',
|
||||
'view_vehicle_status',
|
||||
'list_unknown_customer_vehicles',
|
||||
'list_vehicle_customer_suggestions',
|
||||
'department_license_plate_lookup',
|
||||
'department_vehicle_order_last_five',
|
||||
'list_number_plate_scans',
|
||||
'list_department_number_plate_scanners',
|
||||
'charge_order',
|
||||
'get_payment_intent',
|
||||
'confirm_payment_intent',
|
||||
'modules_stripe_department_terminal_readers_list',
|
||||
'modules_stripe_invoice_send',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'add_bookings',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'list_department_daily_reports',
|
||||
'list_notifications',
|
||||
'list_own_notifications',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
],
|
||||
@@ -102,25 +202,59 @@ class limited_backoffice_service
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_departments',
|
||||
'list_orders',
|
||||
'fetch_order',
|
||||
'add_order',
|
||||
'edit_order',
|
||||
'delete_order',
|
||||
'mark_order_as_completed',
|
||||
'list_order_items',
|
||||
'add_order_items',
|
||||
'edit_order_items',
|
||||
'delete_order_items',
|
||||
'list_order_attachments',
|
||||
'add_order_attachments',
|
||||
'download_order_attachments',
|
||||
'list_products',
|
||||
'list_categories',
|
||||
'list_department_categories',
|
||||
'list_department_order_recommended',
|
||||
'vehicle_product_suggestions',
|
||||
'search_customers',
|
||||
'get_user_from_customer_number',
|
||||
'list_customer_notes',
|
||||
'add_customer_note',
|
||||
'list_customer_attributes',
|
||||
'search_vehicles',
|
||||
'view_vehicle_status',
|
||||
'list_unknown_customer_vehicles',
|
||||
'list_vehicle_customer_suggestions',
|
||||
'department_license_plate_lookup',
|
||||
'department_vehicle_order_last_five',
|
||||
'list_number_plate_scans',
|
||||
'list_department_number_plate_scanners',
|
||||
'charge_order',
|
||||
'get_payment_intent',
|
||||
'confirm_payment_intent',
|
||||
'modules_stripe_department_terminal_readers_list',
|
||||
'modules_stripe_invoice_send',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'add_bookings',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'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,
|
||||
],
|
||||
],
|
||||
@@ -142,6 +276,10 @@ class limited_backoffice_service
|
||||
'group' => 'orders',
|
||||
'capability' => 'view_orders',
|
||||
],
|
||||
'fetch_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'view_orders',
|
||||
],
|
||||
'add_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'create_orders',
|
||||
@@ -154,6 +292,10 @@ class limited_backoffice_service
|
||||
'group' => 'orders',
|
||||
'capability' => 'delete_orders',
|
||||
],
|
||||
'mark_order_as_completed' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'complete_orders',
|
||||
],
|
||||
'list_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'view_order_items',
|
||||
@@ -170,10 +312,110 @@ class limited_backoffice_service
|
||||
'group' => 'orders',
|
||||
'capability' => 'remove_order_lines',
|
||||
],
|
||||
'list_order_attachments' => [
|
||||
'group' => 'attachments',
|
||||
'capability' => 'view_order_attachments',
|
||||
],
|
||||
'add_order_attachments' => [
|
||||
'group' => 'attachments',
|
||||
'capability' => 'add_order_attachments',
|
||||
],
|
||||
'download_order_attachments' => [
|
||||
'group' => 'attachments',
|
||||
'capability' => 'download_order_attachments',
|
||||
],
|
||||
'list_products' => [
|
||||
'group' => 'products',
|
||||
'capability' => 'view_product_catalog',
|
||||
],
|
||||
'list_categories' => [
|
||||
'group' => 'products',
|
||||
'capability' => 'view_product_catalog',
|
||||
],
|
||||
'list_department_categories' => [
|
||||
'group' => 'products',
|
||||
'capability' => 'view_product_catalog',
|
||||
],
|
||||
'list_department_order_recommended' => [
|
||||
'group' => 'products',
|
||||
'capability' => 'view_product_recommendations',
|
||||
],
|
||||
'vehicle_product_suggestions' => [
|
||||
'group' => 'products',
|
||||
'capability' => 'view_product_recommendations',
|
||||
],
|
||||
'search_customers' => [
|
||||
'group' => 'customers',
|
||||
'capability' => 'search_customers',
|
||||
],
|
||||
'get_user_from_customer_number' => [
|
||||
'group' => 'customers',
|
||||
'capability' => 'view_customer_details',
|
||||
],
|
||||
'list_customer_notes' => [
|
||||
'group' => 'customers',
|
||||
'capability' => 'view_customer_notes',
|
||||
],
|
||||
'add_customer_note' => [
|
||||
'group' => 'customers',
|
||||
'capability' => 'add_customer_notes',
|
||||
],
|
||||
'list_customer_attributes' => [
|
||||
'group' => 'customers',
|
||||
'capability' => 'view_customer_flags',
|
||||
],
|
||||
'search_vehicles' => [
|
||||
'group' => 'vehicles',
|
||||
'capability' => 'search_vehicles',
|
||||
],
|
||||
'view_vehicle_status' => [
|
||||
'group' => 'vehicles',
|
||||
'capability' => 'search_vehicles',
|
||||
],
|
||||
'list_unknown_customer_vehicles' => [
|
||||
'group' => 'vehicles',
|
||||
'capability' => 'view_vehicle_matches',
|
||||
],
|
||||
'list_vehicle_customer_suggestions' => [
|
||||
'group' => 'vehicles',
|
||||
'capability' => 'view_vehicle_matches',
|
||||
],
|
||||
'department_license_plate_lookup' => [
|
||||
'group' => 'vehicles',
|
||||
'capability' => 'view_vehicle_history',
|
||||
],
|
||||
'department_vehicle_order_last_five' => [
|
||||
'group' => 'vehicles',
|
||||
'capability' => 'view_vehicle_history',
|
||||
],
|
||||
'list_number_plate_scans' => [
|
||||
'group' => 'scanner',
|
||||
'capability' => 'view_plate_scans',
|
||||
],
|
||||
'list_department_number_plate_scanners' => [
|
||||
'group' => 'scanner',
|
||||
'capability' => 'view_plate_scans',
|
||||
],
|
||||
'charge_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'charge_orders',
|
||||
],
|
||||
'get_payment_intent' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'charge_orders',
|
||||
],
|
||||
'confirm_payment_intent' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'charge_orders',
|
||||
],
|
||||
'modules_stripe_department_terminal_readers_list' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'charge_orders',
|
||||
],
|
||||
'modules_stripe_invoice_send' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'charge_orders',
|
||||
],
|
||||
'list_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'view_department_bookings',
|
||||
@@ -190,6 +432,10 @@ class limited_backoffice_service
|
||||
'group' => 'bookings',
|
||||
'capability' => 'create_bookings',
|
||||
],
|
||||
'add_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'create_bookings',
|
||||
],
|
||||
'complete_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'mark_bookings_complete',
|
||||
@@ -210,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',
|
||||
@@ -226,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',
|
||||
@@ -237,9 +507,16 @@ class limited_backoffice_service
|
||||
*/
|
||||
private const ROLE_PERMISSION_GROUP_ORDER = [
|
||||
'account',
|
||||
'departments',
|
||||
'orders',
|
||||
'products',
|
||||
'customers',
|
||||
'vehicles',
|
||||
'attachments',
|
||||
'scanner',
|
||||
'bookings',
|
||||
'time_bookings',
|
||||
'notifications',
|
||||
'reports',
|
||||
'limited_backoffice',
|
||||
];
|
||||
@@ -286,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>}>
|
||||
@@ -333,6 +627,15 @@ class limited_backoffice_service
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($user->hasPermission('superuser')) {
|
||||
global $db;
|
||||
$rows = $db->fetch_all($db->query(
|
||||
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
|
||||
));
|
||||
|
||||
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
|
||||
}
|
||||
|
||||
global $db;
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?'
|
||||
@@ -504,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>>
|
||||
*/
|
||||
@@ -574,7 +905,7 @@ class limited_backoffice_service
|
||||
|
||||
try {
|
||||
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
|
||||
$customerNumber = $this->generateEmployeeCustomerNumber();
|
||||
$customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
|
||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
@@ -599,15 +930,7 @@ class limited_backoffice_service
|
||||
$employeeId = (int)$mysqli->insert_id;
|
||||
$statement->close();
|
||||
|
||||
$groupName = 'Limited employee #' . $employeeId;
|
||||
$groupDescription = 'Managed by limited backoffice.';
|
||||
$statement = $mysqli->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare group update.');
|
||||
}
|
||||
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
$this->renameManagedGroup($groupId, $employeeId);
|
||||
|
||||
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($departmentJson)) {
|
||||
@@ -641,6 +964,72 @@ class limited_backoffice_service
|
||||
return $this->formatEmployee($employee, $departmentIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function migrateEmployee(users_o $manager, int $employeeId, array $payload): array
|
||||
{
|
||||
$this->rejectRawPermissionPayload($payload);
|
||||
$this->assertNotSelfEdit($manager, $employeeId);
|
||||
|
||||
if ($this->loadManagedEmployee($employeeId) !== null) {
|
||||
throw new limited_backoffice_exception('User is already a limited backoffice employee.', 409);
|
||||
}
|
||||
|
||||
$target = $this->loadMigratableUser($employeeId);
|
||||
if ($target === null) {
|
||||
throw new limited_backoffice_exception('User not found.', 404);
|
||||
}
|
||||
$this->assertMigrationTargetIsSafe($target);
|
||||
|
||||
$departmentIds = $this->normalizeDepartmentIds($payload['department_ids'] ?? null);
|
||||
$this->assertDepartmentSubset($manager, $departmentIds);
|
||||
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
|
||||
|
||||
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($departmentJson)) {
|
||||
throw new limited_backoffice_exception('Unable to encode department metadata.', 500);
|
||||
}
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
|
||||
$this->renameManagedGroup($groupId, $employeeId);
|
||||
$this->updateUserFields($employeeId, [
|
||||
'group_id' => $groupId,
|
||||
]);
|
||||
|
||||
$managerId = (int)$manager->id;
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `limited_backoffice_employees`
|
||||
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`, `updated_by_user_id`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare migrated employee metadata insert.');
|
||||
}
|
||||
$statement->bind_param('iissii', $employeeId, $groupId, $roleKey, $departmentJson, $managerId, $managerId);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
$this->clearUserSessionCache($employeeId);
|
||||
$mysqli->commit();
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to migrate employee.', 500);
|
||||
}
|
||||
|
||||
$employee = $this->loadManagedEmployee($employeeId);
|
||||
if ($employee === null) {
|
||||
throw new limited_backoffice_exception('Unable to load migrated employee.', 500);
|
||||
}
|
||||
|
||||
return $this->formatEmployee($employee, $departmentIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
@@ -1295,6 +1684,19 @@ class limited_backoffice_service
|
||||
return $groupId;
|
||||
}
|
||||
|
||||
private function renameManagedGroup(int $groupId, int $employeeId): void
|
||||
{
|
||||
$groupName = 'Limited employee #' . $employeeId;
|
||||
$groupDescription = 'Managed by limited backoffice.';
|
||||
$statement = $this->mysqli()->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare group update.');
|
||||
}
|
||||
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@@ -1308,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;
|
||||
}
|
||||
@@ -1364,29 +1771,6 @@ class limited_backoffice_service
|
||||
$insert->close();
|
||||
}
|
||||
|
||||
private function generateEmployeeCustomerNumber(): int
|
||||
{
|
||||
$mysqli = $this->mysqli();
|
||||
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||
$customerNumber = random_int(900000000, 999999999);
|
||||
$statement = $mysqli->prepare('SELECT `id` FROM `users` WHERE `customer_number` = ? LIMIT 1');
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare customer number check.');
|
||||
}
|
||||
$statement->bind_param('i', $customerNumber);
|
||||
$statement->execute();
|
||||
$result = $statement->get_result();
|
||||
$exists = $result->num_rows > 0;
|
||||
$statement->close();
|
||||
|
||||
if (!$exists) {
|
||||
return $customerNumber;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Unable to generate employee customer number.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
@@ -1424,6 +1808,42 @@ class limited_backoffice_service
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function loadMigratableUser(int $employeeId): ?array
|
||||
{
|
||||
global $db;
|
||||
$userDeletedAtSelect = $this->tableHasColumn('users', 'deleted_at')
|
||||
? 'u.`deleted_at` AS `user_deleted_at`'
|
||||
: 'NULL AS `user_deleted_at`';
|
||||
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT
|
||||
u.`id`,
|
||||
u.`customer_number`,
|
||||
u.`display_name`,
|
||||
u.`email`,
|
||||
u.`phone_country_code`,
|
||||
u.`phone`,
|
||||
u.`group_id`,
|
||||
' . $userDeletedAtSelect . '
|
||||
FROM `users` u
|
||||
WHERE u.`id` = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to load user.', 500);
|
||||
}
|
||||
$statement->bind_param('i', $employeeId);
|
||||
$statement->execute();
|
||||
$result = $statement->get_result();
|
||||
$row = $db->fetch_assoc($result);
|
||||
$statement->close();
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
@@ -1518,6 +1938,25 @@ class limited_backoffice_service
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $target
|
||||
*/
|
||||
private function assertMigrationTargetIsSafe(array $target): void
|
||||
{
|
||||
if ((int)($target['customer_number'] ?? -1) !== self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER) {
|
||||
throw new limited_backoffice_exception('Only employee accounts with customer number 0 can be migrated.', 400);
|
||||
}
|
||||
|
||||
$groupId = (int)($target['group_id'] ?? 0);
|
||||
if ($groupId === 1 || $this->groupHasPermission($groupId, 'superuser')) {
|
||||
throw new limited_backoffice_exception('Cannot migrate superuser accounts.', 403);
|
||||
}
|
||||
|
||||
if (($target['user_deleted_at'] ?? null) !== null) {
|
||||
throw new limited_backoffice_exception('Cannot migrate inactive users.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
private function groupHasPermission(int $groupId, string $permission): bool
|
||||
{
|
||||
if ($groupId <= 0) {
|
||||
|
||||
@@ -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'];
|
||||
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
|
||||
{
|
||||
global $db;
|
||||
(new xlvask_usage_logs_o())->structure();
|
||||
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
|
||||
$where = [
|
||||
'FinishStatus = 1',
|
||||
'(ignored_at IS NULL OR ignored_at = "")',
|
||||
];
|
||||
|
||||
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
} else {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
}
|
||||
|
||||
if ($dateTo !== null && strtotime($dateTo) !== false) {
|
||||
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
}
|
||||
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper
|
||||
|
||||
private function unsetNullifiableProperties(): void
|
||||
{
|
||||
$nullable_review_metadata = [
|
||||
'ignored_at',
|
||||
'ignored_reason',
|
||||
];
|
||||
// Unset properties that are null or empty strings
|
||||
$properties = [
|
||||
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
|
||||
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
|
||||
if ($this->isEmptyOrDefault($this->{$property})) {
|
||||
$tmp_value = $this->{$property};
|
||||
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) {
|
||||
$this->{$property} = ''; // Set to null if it matches the default string
|
||||
$this->{$property} = (
|
||||
$tmp_value === $this->default_string_nullable
|
||||
&& in_array($property, $nullable_review_metadata, true)
|
||||
) ? null : '';
|
||||
} elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
|
||||
if ($tmp_value === $this->default_int_nullable) {
|
||||
$this->{$property} = null; // Set to null if it matches the default int nullable
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,7 +82,7 @@ class xlvask_usage_logs_o extends db
|
||||
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
|
||||
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
|
||||
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false);
|
||||
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
|
||||
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
|
||||
}
|
||||
@@ -195,18 +195,20 @@ class xlvask_usage_logs_o extends db
|
||||
|
||||
/**
|
||||
* Import the usage logs from XL Vask
|
||||
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
|
||||
* @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'.
|
||||
* @param string|null $dateTo Optional inclusive import end date.
|
||||
* @throws Exception If the objects were not successfully added.
|
||||
* @returns void
|
||||
*/
|
||||
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void
|
||||
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
|
||||
{
|
||||
if (!empty($this->id)) {
|
||||
throw new Exception('To prevent issues, having a selected object is not allowed.');
|
||||
}
|
||||
$usage_logs = $this->getUsageLogsFromXLVask(
|
||||
date('Y-m-d\TH:i:s.000', strtotime($dateTimeModifier)) // Example: '2025-05-01T00:00:00.000'
|
||||
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
|
||||
);
|
||||
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
|
||||
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
|
||||
$known_usage_logIds = array_map(function ($log) {
|
||||
return $log['WashId'];
|
||||
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
|
||||
unset($new_usage_logs);
|
||||
}
|
||||
|
||||
private static function formatImportDateFrom(?string $dateFrom): string
|
||||
{
|
||||
$dateFrom = trim((string)($dateFrom ?? ''));
|
||||
$timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom);
|
||||
|
||||
if ($timestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateFrom');
|
||||
}
|
||||
|
||||
return date('Y-m-d\TH:i:s.000', $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param xlvask_usage_log[] $usageLogs
|
||||
* @return xlvask_usage_log[]
|
||||
* @throws Exception
|
||||
*/
|
||||
private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array
|
||||
{
|
||||
$dateTo = trim((string)($dateTo ?? ''));
|
||||
if ($dateTo === '') {
|
||||
return $usageLogs;
|
||||
}
|
||||
|
||||
$dateToTimestamp = strtotime($dateTo);
|
||||
if ($dateToTimestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateTo');
|
||||
}
|
||||
|
||||
$inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp));
|
||||
if ($inclusiveEndTimestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateTo');
|
||||
}
|
||||
|
||||
return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) {
|
||||
$startTimestamp = strtotime((string)$log->StartTime);
|
||||
return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function retrieves the usage logs from XL Vask
|
||||
* @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
|
||||
|
||||
+931
-76
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -78,6 +105,15 @@ class limitedBackofficeRoute
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->post('/limited-backoffice/employees/{employeeId}/migrate', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission('superuser');
|
||||
return $service->migrateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
|
||||
});
|
||||
}, [
|
||||
'superuser' => 'Migrate existing employees to limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
@@ -145,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
|
||||
$this->get('/modules/xlvask/tasks/import-usage', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_xlvask_import_usage');
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
|
||||
// Create the xlvask_usage_logs_o object
|
||||
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
|
||||
// Import usage logs
|
||||
$xlvask_usage_logs_o->importUsageLogs();
|
||||
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
|
||||
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
|
||||
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
|
||||
// Response
|
||||
$response->success(
|
||||
'Usage logs imported',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,50 +20,121 @@ 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
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['customer_id'])) {
|
||||
$customerId = (int)self::getParameter('customer_id');
|
||||
try {
|
||||
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
|
||||
if ($customerObject->exists()) {
|
||||
return $customerObject;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||
// Return null
|
||||
return null;
|
||||
}
|
||||
$customerId = $this->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()) {
|
||||
return $customerObject;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||
// Return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
private function getDepartmentIdIfProvided(): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['department_id'])) {
|
||||
return (int)self::getParameter('department_id');
|
||||
}
|
||||
return null;
|
||||
return $this->getOptionalPositiveIntParameter('department_id');
|
||||
}
|
||||
|
||||
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
|
||||
private function getOptionalPositiveIntParameter(string $parameter): ?int
|
||||
{
|
||||
if (!$user instanceof users_o || $departmentId === null) {
|
||||
return;
|
||||
global $response;
|
||||
if (!self::isParametersSet([$parameter])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->hasPermission('superuser_fetch_department')) {
|
||||
return;
|
||||
$value = self::getParameter($parameter);
|
||||
if ($this->isNullLikeOptionalParameter($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->requirePermission('department_access_' . $departmentId);
|
||||
$parsed = null;
|
||||
if (is_int($value)) {
|
||||
$parsed = $value;
|
||||
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
|
||||
$parsed = (int)trim($value);
|
||||
} else {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
if ($parsed === null || $parsed <= 0) {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isNullLikeOptionalParameter(mixed $value): bool
|
||||
{
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
|
||||
}
|
||||
|
||||
private function isCustomerBookingSession(bool $hasAuthenticatedUser, bool $hasCustomerPermission, bool $isSubuserSession): bool
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return $customerId === null || $this->isOwnCustomerContext($customerId);
|
||||
}
|
||||
|
||||
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);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,11 +143,7 @@ class productsRoute
|
||||
*/
|
||||
private function getCategoryIfProvided(): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['category'])) {
|
||||
return (int)self::getParameter('category');
|
||||
}
|
||||
return null;
|
||||
return $this->getOptionalPositiveIntParameter('category');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,11 +152,7 @@ class productsRoute
|
||||
*/
|
||||
private function getProductIdIfProvided(): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['id'])) {
|
||||
return (int)self::getParameter('id');
|
||||
}
|
||||
return null;
|
||||
return $this->getOptionalPositiveIntParameter('id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,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);
|
||||
}
|
||||
@@ -147,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'],
|
||||
@@ -205,18 +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 = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
||||
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||
$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)
|
||||
// Check if the "final_price" parameter is set, and true.
|
||||
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
|
||||
if ($useFinalPrice) {
|
||||
// Determine the products to return
|
||||
if ($category) {
|
||||
// Get products in the category
|
||||
@@ -239,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
|
||||
@@ -267,60 +356,72 @@ 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
|
||||
)
|
||||
);
|
||||
}
|
||||
// Check if the category is set in the request
|
||||
$data = $_GET ?? [];
|
||||
// Check if the category is set
|
||||
if (isset($data['category'])) {
|
||||
if ($category !== null) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
|
||||
// Return the list of products
|
||||
$products = (new products_o())->listObjectsByCategory($data['category']);
|
||||
$products = (new products_o())->listObjectsByCategory($category);
|
||||
if ($restrictCustomerBookingProducts) {
|
||||
$products = $this->filterProductsVisibleOnBookingForm($products);
|
||||
}
|
||||
// Check if the department_id is set
|
||||
if (isset($data['department_id'])) {
|
||||
if ($departmentId !== null) {
|
||||
// Apply the departments unique pricing
|
||||
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
|
||||
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
|
||||
}
|
||||
$response->success(
|
||||
array_map(function ($product) use ($isProductDetailsRestricted) {
|
||||
return parseProduct($product, $isProductDetailsRestricted);
|
||||
array_map(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
|
||||
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
|
||||
}, $products)
|
||||
);
|
||||
}
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
|
||||
// Check if the department_id is set
|
||||
if (isset($data['department_id'])) {
|
||||
if ($departmentId !== null) {
|
||||
// Get all product ids contained in a category attached to the department
|
||||
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
|
||||
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
|
||||
// Get the product ids as an array
|
||||
$departmentSpecificProductIds = array_map(function ($product) {
|
||||
return $product->id;
|
||||
}, $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,
|
||||
])
|
||||
), (int)$data['department_id'])
|
||||
(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');
|
||||
@@ -329,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.'
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
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');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Null Query Product',
|
||||
'price' => 400,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/products?id=' . (int)$product['id']
|
||||
. '&department_id=null&customer_id=null&category_id=null&final_price=false',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())
|
||||
->toBeArray()
|
||||
->toHaveKey('id', (int)$product['id']);
|
||||
expect($response->body)->not->toContain('department_access_0');
|
||||
});
|
||||
|
||||
it('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,
|
||||
]);
|
||||
products_api_department_price((int)$department['id'], (int)$product['id'], 375);
|
||||
$session = api_fixtures()->createUserSession(['user']);
|
||||
|
||||
$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()
|
||||
->assertSuccess(false)
|
||||
->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');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Invalid Department Product',
|
||||
'price' => 600,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['user']);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid department_id');
|
||||
|
||||
expect($response->body)->not->toContain('department_access_0');
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
+267
@@ -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();
|
||||
}
|
||||
});
|
||||
+16
@@ -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']);
|
||||
}
|
||||
}
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+16
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+50
@@ -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,6 +3,20 @@
|
||||
use helpers\xlvask_usage_log;
|
||||
use objects\xlvask_usage_logs_o;
|
||||
|
||||
it('serializes empty ignore metadata as SQL null values for new usage logs', function (): void {
|
||||
$log = new xlvask_usage_log();
|
||||
$data = $log->toArray();
|
||||
|
||||
expect($data)
|
||||
->toHaveKey('ignored_at')
|
||||
->toHaveKey('ignored_by')
|
||||
->toHaveKey('ignored_reason')
|
||||
->and($data['ignored_at'])->toBeNull()
|
||||
->and($data['ignored_by'])->toBeNull()
|
||||
->and($data['ignored_reason'])->toBeNull()
|
||||
->and($data['Updated'])->toBe('');
|
||||
});
|
||||
|
||||
it('accepts persisted ignore metadata from xlvask usage log rows', function (): void {
|
||||
$log = new xlvask_usage_log();
|
||||
|
||||
@@ -57,3 +71,21 @@ it('calculates XL Vask amount summaries without hydrating order item previews',
|
||||
'primary_product_name' => 'Stor bil',
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats date-only XL Vask usage import start dates for the upstream API', function (): void {
|
||||
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'formatImportDateFrom');
|
||||
|
||||
expect($method->invoke(null, '2026-03-01'))->toBe('2026-03-01T00:00:00.000');
|
||||
});
|
||||
|
||||
it('filters fetched XL Vask usage logs inclusively to the requested import end date', function (): void {
|
||||
$keep = new xlvask_usage_log(['StartTime' => '2026-03-31T23:59:59.000']);
|
||||
$drop = new xlvask_usage_log(['StartTime' => '2026-04-01T00:00:00.000']);
|
||||
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'filterUsageLogsUntil');
|
||||
|
||||
$result = $method->invoke(null, [$keep, $drop], '2026-03-31');
|
||||
|
||||
expect($result)
|
||||
->toHaveCount(1)
|
||||
->and($result[0])->toBe($keep);
|
||||
});
|
||||
|
||||
@@ -43,3 +43,22 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
|
||||
->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']")
|
||||
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
|
||||
});
|
||||
|
||||
it('scopes manual XL Vask usage import and automation to optional period dates', function (): void {
|
||||
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
|
||||
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||
|
||||
expect($route)
|
||||
->not->toBeFalse()
|
||||
->and($automation)->not->toBeFalse();
|
||||
|
||||
$route = (string)$route;
|
||||
$automation = (string)$automation;
|
||||
|
||||
expect($route)
|
||||
->toContain("getParameter('dateFrom')")
|
||||
->toContain("getParameter('dateTo')")
|
||||
->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)')
|
||||
->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
|
||||
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user