Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard ce43c4e064 Expose limited backoffice role permission templates 2026-07-07 03:23:42 +02:00
Jeppe B 579ddcf510 Merge pull request #307 from copenhagentruckwash/copilot/update-limited-backoffice-roles
Fix limited-backoffice role permissions and enforce department access on order mutations
2026-07-07 02:53:06 +02:00
Jeppe Bundgaard 0b342a7780 Align limited backoffice permission cap tests 2026-07-07 02:47:49 +02:00
copilot-swe-agent[bot] 57bcbaf72a Fix 11 failing API tests across 4 files 2026-07-07 00:31:43 +00:00
copilot-swe-agent[bot] d9fbba3130 Return 404 when order item not found in DELETE /order/items 2026-07-06 23:19:00 +00:00
copilot-swe-agent[bot] e4465d9d91 Improve DELETE /order/items: clearer error message, 404 when order not found 2026-07-06 23:17:50 +00:00
copilot-swe-agent[bot] 734cd13c87 Handle prepared statement failure with error response in DELETE /order/items 2026-07-06 23:16:55 +00:00
copilot-swe-agent[bot] d0f94ac549 Use prepared statements for all new DB queries in tests and route 2026-07-06 23:15:59 +00:00
copilot-swe-agent[bot] 1d25cbe21c Fix SQL injection concerns: use prepared statements in orderItemsRoute and tests 2026-07-06 23:14:34 +00:00
copilot-swe-agent[bot] 53d0636193 Fix limited-backoffice permissions and add department access restrictions 2026-07-06 23:12:01 +00:00
Jeppe B 04bb26f1b0 Merge pull request #306 from copenhagentruckwash/copilot/fix-php-api-job-failure
Fix two failing LimitedBackofficeApiTest assertions
2026-07-07 00:44:53 +02:00
copilot-swe-agent[bot] df0d4783d0 Remove search_customers and search_vehicles from raw-permission exclusion list 2026-07-06 22:37:40 +00:00
copilot-swe-agent[bot] 39c06ceab6 Address code review: restore filter passthrough and add clarifying comments 2026-07-06 22:28:45 +00:00
copilot-swe-agent[bot] 7dd428d18e Fix two failing LimitedBackofficeApiTest tests
Fix 1: Remove add_order_attachments and download_order_attachments from
the raw-permissions exclusion check in the test. These strings are valid
capability names that legitimately appear in the /limited-backoffice/roles
response, so including them in the 'should not contain' list caused a
false failure.

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
Jeppe Bundgaard 0cca597fdc Fix XLVask usage import dates
Fix XLVask usage-log import metadata and period-scoped Selvvask automation.
2026-07-06 23:49:28 +02:00
Jeppe B 709c6acbba Fix product null department permissions
Treats null-like optional product query params as omitted and avoids department_access_0 permission checks.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard ed2736e528 Fix product null department permissions 2026-07-06 19:56:07 +02:00
Jeppe B c7f5c73a9e Merge pull request #303 from copenhagentruckwash/codex/daily-report-product-targets-api
[codex] Add daily report product target API
2026-07-06 19:35:37 +02:00
Jeppe Bundgaard c10af48954 Add daily report product target API 2026-07-06 18:52:24 +02:00
Jeppe Bundgaard 7ac5c5585b Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe B 8544ce0a18 Merge pull request #297 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price overrides
2026-07-06 17:23:06 +02:00
Jeppe Bundgaard 614715822f Fix backend merge fallout for booking and limited employees 2026-07-06 17:16:22 +02:00
Jeppe Bundgaard 1da02e2486 Fix limited backoffice price save reset 2026-07-06 17:13:04 +02:00
Jeppe B 742b15116d Merge pull request #295 from copenhagentruckwash/fix/economic-ean-transfer
Fix e-conomic EAN customer transfer
2026-07-06 17:00:57 +02:00
Jeppe Bundgaard e0ae74bdc2 Merge remote-tracking branch 'origin/master' into codex/customer-product-fixed-price-overrides 2026-07-06 17:00:52 +02:00
Jeppe Bundgaard 08dc803b3e Make limited backoffice employees regular employees 2026-07-06 16:59:32 +02:00
Jeppe Bundgaard 248a901f24 Merge master into fixed price override branch 2026-07-06 16:54:01 +02:00
Jeppe Bundgaard 8bbdf9daf5 Require booking add node for subuser booking creation 2026-07-06 16:48:07 +02:00
Jeppe B c089186046 Merge pull request #302 from copenhagentruckwash/codex/customer-orderbooking-create-without-permission
Allow customer order booking creation without booking permission
2026-07-06 16:39:17 +02:00
Jeppe Bundgaard 2ae1fc3fcf Allow customer order booking creation without booking permission 2026-07-06 16:33:31 +02:00
Jeppe Bundgaard d9eacf6f84 Deduplicate limited backoffice price products 2026-07-06 16:28:08 +02:00
Jeppe B f262047476 Merge pull request #300 from copenhagentruckwash/codex/scoped-monthly-split-api
Scope monthly invoice split API
2026-07-06 16:01:54 +02:00
Jeppe B b8390ac0d3 Merge pull request #298 from copenhagentruckwash/codex/only-tankcleaning-order-enforcement
Enforce only tankcleaning order products
2026-07-06 16:01:40 +02:00
Jeppe B 0d4a5470e5 Add superuser department overview API (#301)
Merge backend API for the superuser department overview.
2026-07-06 16:01:04 +02:00
Jeppe B 845ca6e48e Merge pull request #290 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing enforcement
2026-07-06 15:31:27 +02:00
Jeppe Bundgaard 1cda2a81aa Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	services/nginx/app/tests/Api/OrderItemsApiTest.php
2026-07-06 15:21:31 +02:00
Jeppe BandJeppe Bundgaard 8e46ce1b04 [codex] Allow error reports without screenshots (#299)
* Allow error reports without screenshots

* Stabilize edge gateway shell transcript smoke

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:27:47 +02:00
Jeppe BandJeppe Bundgaard 11c2a1b72e Block restricted customer order items (#296)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:06:29 +02:00
Jeppe Bundgaard 62f2c80dda Scope monthly invoice split endpoint 2026-07-06 13:37:31 +02:00
Jeppe Bundgaard 430c90cbca Enforce only tankcleaning order products 2026-07-06 12:53:42 +02:00
Jeppe Bundgaard f02dfd8c9c Add customer product fixed price overrides 2026-07-06 12:52:33 +02:00
Jeppe Bundgaard db1b9a2c96 Fix e-conomic EAN customer transfer 2026-07-06 11:34:23 +02:00
Jeppe Bundgaard 84dec4c0a2 Stabilize custom pricing API fixture 2026-07-06 10:34:57 +02:00
Jeppe Bundgaard d47ea1d659 Add custom-only department pricing enforcement 2026-07-06 10:15:11 +02:00
74 changed files with 5784 additions and 311 deletions
+124 -3
View File
@@ -40,6 +40,8 @@ tags:
description: Account security and passkey management endpoints
- name: Users
description: User management and customer operations
- name: Limited Backoffice
description: Limited backoffice employee and department management
- name: Search
description: System-wide search endpoints
- name: Orders
@@ -2762,6 +2764,44 @@ paths:
properties:
token: {type: string}
/limited-backoffice/employees/{employeeId}/login-link:
post:
tags:
- Limited Backoffice
summary: Create a managed employee QR login link
description: Create a reusable auth-token login link for an active employee managed through the limited backoffice.
operationId: createLimitedBackofficeEmployeeLoginLink
parameters:
- name: employeeId
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Login link created successfully
content:
application/json:
schema:
type: object
properties:
employee_id:
type: integer
login_path:
type: string
example: /login/qr?token=abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
# User Endpoints
/users:
get:
@@ -12405,6 +12445,29 @@ paths:
application/json:
schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions:
post:
tags:
@@ -12717,6 +12780,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -13388,7 +13478,6 @@ components:
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
@@ -13404,10 +13493,11 @@ components:
description: What actually happened
data_collection_accepted:
type: boolean
description: Required acceptance of collecting screenshot and diagnostic error data
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
screenshot:
type: string
description: PNG, JPEG, or WebP data URI of the current app viewport
nullable: true
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
route_path:
type: string
nullable: true
@@ -13518,6 +13608,7 @@ components:
nullable: true
screenshot:
type: object
nullable: true
additionalProperties: true
answers:
type: object
@@ -21525,6 +21616,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
+13 -15
View File
@@ -928,22 +928,20 @@ async function main() {
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
await waitForCondition(
async () => {
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
},
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
@@ -137,6 +137,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
@@ -0,0 +1,98 @@
<?php
namespace classes;
use RuntimeException;
class customer_order_product_policy
{
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
{
$message = self::orderProductViolationMessage($orderId, $productId);
if ($message !== null) {
throw new RuntimeException($message);
}
}
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
{
$context = self::loadOrderProductContext($orderId, $productId);
if ($context === null) {
return null;
}
if ((int)($context['product_id'] ?? 0) < 1) {
return null;
}
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
? self::ONLY_TANKCLEANING_MESSAGE
: null;
}
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
{
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
}
public static function isTankCleaningProductRow(array $row): bool
{
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
}
private static function loadOrderProductContext(int $orderId, int $productId): ?array
{
global $db;
if ($orderId < 1 || $productId < 1) {
return null;
}
$sql = "
SELECT
o.id AS order_id,
o.customer_id AS customer_number,
p.id AS product_id,
p.name AS product_name,
p.category AS product_category,
c.name AS category_name,
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
FROM orders o
LEFT JOIN products p ON p.id = {$productId}
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN users u ON u.customer_number = o.customer_id
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
WHERE o.id = {$orderId}
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
LIMIT 1
";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
}
private static function rowMatchesProductTerms(array $row, array $terms): bool
{
$haystack = strtolower(trim(
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
));
foreach ($terms as $term) {
if ($term !== '' && str_contains($haystack, strtolower($term))) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,158 @@
<?php
namespace classes;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
/**
* @return array{rule:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
$order = (new orders_o())->getOrderById($orderId);
if (!$order->exists()) {
return null;
}
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
return null;
}
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
if (!$customer->exists()) {
return null;
}
$categoryId = (int)$product->category->value();
$categoryName = $this->categoryName($categoryId);
$searchableProduct = $this->searchableProductText($product, $categoryName);
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
return $this->violation('restrictAdditionalServices');
}
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
return $this->violation('restrictTankCleaning');
}
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
return $this->violation('onlyTankCleaning');
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
return $this->violation('restrictSpotFree');
}
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
return $this->violation('restrictInteriorCleaning');
}
return null;
}
/**
* @return array{rule:string,message:string}
*/
private function violation(string $rule): array
{
return [
'rule' => $rule,
'message' => self::BLOCK_MESSAGE,
];
}
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
{
if ($relatedItemId !== null && $relatedItemId > 0) {
return true;
}
if ($categoryId === self::ADDON_CATEGORY_ID) {
return true;
}
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
return true;
}
return $this->countStandaloneOrderItems($orderId) > 0;
}
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
{
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
return true;
}
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
}
/**
* @param array<int, string> $terms
*/
private function containsAny(string $value, array $terms): bool
{
foreach ($terms as $term) {
if ($term !== '' && str_contains($value, $term)) {
return true;
}
}
return false;
}
private function categoryName(int $categoryId): string
{
global $db;
if ($categoryId <= 0) {
return '';
}
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
if (!$result || $result->num_rows === 0) {
return '';
}
$row = $result->fetch_assoc();
return strtolower((string)($row['name'] ?? ''));
}
private function countStandaloneOrderItems(int $orderId): int
{
global $db;
$result = $db->query(
'SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = ' . $orderId . '
AND deleted_at IS NULL
AND (related_item_id IS NULL OR related_item_id = 0)'
);
if (!$result) {
return 0;
}
$row = $result->fetch_assoc();
return (int)($row['item_count'] ?? 0);
}
}
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
);
}
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
AFTER archived"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email,
int $phone,
?int $mobile_phone = null,
object|array|null $company_information = null
object|array|null $company_information = null,
?string $ean = null
): object
{
$payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
$normalized_ean = self::normalizeCustomerEan($ean);
if ($normalized_ean !== null) {
$payload['ean'] = $normalized_ean;
}
return $this->customers->customers->create($payload);
}
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{
if ($company_information === null) {
@@ -389,6 +389,15 @@ class economic_v2_distribution_service
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
if ($discount_row === null) {
continue;
}
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$fixed_price = (float)$discount_row['fixed_price'];
$order_discount_total += (($base_price - $fixed_price) * $quantity);
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) {
continue;
@@ -1520,6 +1529,12 @@ class economic_v2_distribution_service
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
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;
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100));
@@ -1537,7 +1552,10 @@ class economic_v2_distribution_service
}
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
if ($direct !== null && (
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|| (int)($direct['discount'] ?? 0) > 0
)) {
return $this->discount_resolution_cache[$cache_key] = $direct;
}
@@ -65,6 +65,7 @@ class economic_v2_schema_bootstrap
is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -83,6 +84,14 @@ class economic_v2_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER discount"
);
}
self::$initialized = true;
}
@@ -106,4 +115,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -135,7 +135,8 @@ class economic_v2_versioning_service
string $source = 'live.discount_override',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
array $metadata = [],
?int $fixed_price = null
): array {
$identity = [
'user_id' => $user_id,
@@ -144,7 +145,7 @@ class economic_v2_versioning_service
'object_id' => (string)$object_id,
];
if ($discount === null || (int)$discount === 0) {
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion(
'customer_discount_override_versions',
$identity,
@@ -161,6 +162,7 @@ class economic_v2_versioning_service
$identity,
[
'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
],
$this->normalizeDatetime($effective_from),
$source,
@@ -411,8 +413,11 @@ class economic_v2_versioning_service
}
// Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
($has_override_fixed_price ? ', po.fixed_price' : '') .
($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll(
"SELECT $discount_cols
@@ -434,7 +439,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override',
$confidence,
true,
['table' => 'price_overrides']
['table' => 'price_overrides'],
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
);
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
}
@@ -707,4 +713,3 @@ class economic_v2_versioning_service
$bucket[$action]++;
}
}
@@ -92,12 +92,17 @@ class error_report_service
throw new RuntimeException('Data collection acceptance is required.');
}
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
$runtimeContext = $this->runtimeContext($payload, $context);
$runtimeContext['screenshot_attachment'] = [
'status' => $storedScreenshot['status'],
'attached' => $storedScreenshot['key'] !== '',
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
'size_bytes' => (int)$storedScreenshot['size_bytes'],
];
$this->execute(
"INSERT INTO error_reports (
@@ -295,6 +300,67 @@ class error_report_service
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function storeOptionalScreenshot(mixed $value, array $context): array
{
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
return $this->emptyScreenshotAttachment('invalid');
}
$dataUri = trim((string)($value ?? ''));
if ($dataUri === '') {
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
}
try {
$screenshot = self::decodeScreenshotDataUri($dataUri);
} catch (RuntimeException $exception) {
$message = strtolower($exception->getMessage());
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
}
try {
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
} catch (Throwable) {
return $this->emptyScreenshotAttachment('storage_failed');
}
return [
'key' => (string)($storedScreenshot['key'] ?? ''),
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
'status' => 'stored',
];
}
private function emptyScreenshotAttachment(string $status): array
{
return [
'key' => '',
'mime_type' => '',
'size_bytes' => 0,
'status' => $status,
];
}
private function contextScreenshotStatus(array $context): ?string
{
$attachment = $context['screenshot_attachment'] ?? null;
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
return $this->normalizeEmptyScreenshotStatus($status);
}
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
{
$status = strtolower(trim((string)$status));
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
return $status;
}
return null;
}
private function runtimeContext(array $payload, array $context): array
{
return [
@@ -432,6 +498,10 @@ class error_report_service
private function publicReport(array $row, bool $includeDetail): array
{
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
@@ -449,10 +519,10 @@ class error_report_service
'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null,
'screenshot' => [
'mime_type' => $row['screenshot_mime_type'] ?? null,
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
],
'screenshot' => $hasScreenshot ? [
'mime_type' => $screenshotMimeType,
'size_bytes' => $screenshotSizeBytes,
] : null,
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
@@ -467,8 +537,11 @@ class error_report_service
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
if ($hasScreenshot) {
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
}
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
@@ -30,6 +30,7 @@ class invoice_period_flag_service
public function __construct()
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
}
public function createManualFlag(array $payload, int $userId): array
@@ -688,6 +689,7 @@ class invoice_period_flag_service
o.po AS order_po,
o.notes AS order_notes,
o.department_id,
d.custom_pricing_only AS department_custom_pricing_only,
o.reg_1,
o.invoice_collection_id,
o.wash_id,
@@ -711,6 +713,7 @@ class invoice_period_flag_service
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
FROM orders o
LEFT JOIN (
@@ -720,11 +723,12 @@ class invoice_period_flag_service
GROUP BY customer_number
) u ON u.customer_number = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
LEFT JOIN departments d ON d.id = o.department_id
LEFT JOIN products p ON p.id = oi.product_id
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
LEFT JOIN (
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0
@@ -1921,7 +1925,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int
{
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
if ($customMissingPrice) {
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
}
$fixedPrice = $this->rowProductFixedPrice($row);
if ($fixedPrice !== null) {
return $fixedPrice;
}
$base = $row['department_price'] !== null
? (int)$row['department_price']
: (int)($row['product_base_price'] ?? 0);
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100)));
}
@@ -1929,13 +1945,18 @@ class invoice_period_flag_service
private function priceBreakdown(array $row, int $expected): array
{
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
$discount = $this->discountBreakdown($row);
if ($customMissingPrice) {
$discount['applied_discount_percentage'] = 0;
}
return [
'product_price' => (int)($row['product_base_price'] ?? 0),
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
'department_price' => $departmentPrice,
'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1944,21 +1965,36 @@ class invoice_period_flag_service
];
}
private function isCustomMissingDepartmentPrice(array $row): bool
{
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
}
private function discountBreakdown(array $row): array
{
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [
'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount,
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
'applied_discount_percentage' => $appliedDiscount,
];
}
private function rowProductFixedPrice(array $row): ?int
{
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
? (int)$row['product_fixed_price']
: null;
}
private function economicCustomerDiscountPercentage(array $row): int
{
$customerNumber = (int)($row['customer_number'] ?? 0);
@@ -2036,8 +2072,7 @@ class invoice_period_flag_service
private function rowIsTankCleaningProduct(array $row): bool
{
return (int)($row['product_category'] ?? 0) === 5
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
return customer_order_product_policy::isTankCleaningProductRow($row);
}
private function isIncludedOrderItem(array $row): bool
@@ -3,6 +3,8 @@
namespace classes;
use mysqli;
use objects\logs_o;
use objects\products_o;
use objects\users_o;
class limited_backoffice_service
@@ -11,13 +13,44 @@ class limited_backoffice_service
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_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, 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',
@@ -25,18 +58,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' => [
@@ -45,16 +118,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' => [
@@ -63,21 +143,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',
],
@@ -88,21 +200,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',
self::PERMISSION_ACCESS,
@@ -128,6 +272,10 @@ class limited_backoffice_service
'group' => 'orders',
'capability' => 'view_orders',
],
'fetch_order' => [
'group' => 'orders',
'capability' => 'view_orders',
],
'add_order' => [
'group' => 'orders',
'capability' => 'create_orders',
@@ -140,6 +288,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',
@@ -156,10 +308,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',
@@ -176,6 +428,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',
@@ -196,6 +452,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',
@@ -223,9 +495,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',
];
@@ -247,26 +526,48 @@ class limited_backoffice_service
public function __construct()
{
departments_schema_bootstrap::ensureTables();
limited_backoffice_schema_bootstrap::ensureTables();
}
/**
* @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,string>}>}>
*/
public function rolePresets(): array
public function rolePresets(?users_o $manager = null): array
{
$roles = [];
foreach (self::ROLE_PRESETS as $key => $preset) {
$permissions = $manager === null
? $preset['permissions']
: $this->effectiveRolePermissionsForManager($manager, $key, false);
$roles[] = [
'key' => $key,
'label' => $preset['label'],
'description' => $preset['description'],
'permission_groups' => $this->rolePermissionGroups($preset['permissions']),
'permission_groups' => $this->rolePermissionGroups($permissions),
];
}
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>}>
@@ -314,6 +615,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` = ?'
@@ -356,7 +666,7 @@ class limited_backoffice_service
$in = implode(',', array_map('intval', $departmentIds));
$sql = "
SELECT `id`, `name`, `description`, `visible`, `archived`
SELECT `id`, `name`, `description`, `visible`, `archived`, `custom_pricing_only`
FROM `departments`
WHERE `id` IN ($in)
ORDER BY `order_priority` ASC, `name` ASC, `id` ASC
@@ -371,6 +681,7 @@ class limited_backoffice_service
'description' => (string)($row['description'] ?? ''),
'visible' => (bool)($row['visible'] ?? false),
'archived' => (bool)($row['archived'] ?? false),
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
], $rows);
}
@@ -386,8 +697,9 @@ class limited_backoffice_service
throw new limited_backoffice_exception('Department not found', 404);
}
$catalog = $this->departmentProductCatalog($departmentId);
if ($catalog['missing_products'] !== []) {
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
if (!$customPricingOnly && $catalog['missing_products'] !== []) {
throw new limited_backoffice_exception('Department price setup is incomplete.', 409, [
'message' => 'Department price setup is incomplete.',
'code' => 'department_price_setup_required',
@@ -419,7 +731,8 @@ class limited_backoffice_service
throw new limited_backoffice_exception('Department not found', 404);
}
$catalog = $this->departmentProductCatalog($departmentId);
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
if ($catalog['required_product_ids'] === []) {
throw new limited_backoffice_exception('Department has no products configured.', 409);
}
@@ -436,7 +749,7 @@ class limited_backoffice_service
sort($providedProductIds);
$missingProductIds = array_values(array_diff($requiredProductIds, $providedProductIds));
if ($missingProductIds !== []) {
if (!$customPricingOnly && $missingProductIds !== []) {
throw new limited_backoffice_exception('Price is required for every department product.', 400, [
'message' => 'Price is required for every department product.',
'missing_product_ids' => $missingProductIds,
@@ -453,26 +766,26 @@ class limited_backoffice_service
$mysqli->begin_transaction();
try {
$priceUpdateAssignments = ['`price` = VALUES(`price`)'];
if ($this->tableHasColumn('product_department_prices', 'updated_at')) {
$priceUpdateAssignments[] = '`updated_at` = CURRENT_TIMESTAMP';
}
$statement = $mysqli->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE ' . implode(', ', $priceUpdateAssignments)
$deleteStatement = $mysqli->prepare(
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
);
if ($statement === false) {
$insertStatement = $mysqli->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`) VALUES (?, ?, ?)'
);
if ($deleteStatement === false || $insertStatement === false) {
throw new \RuntimeException('Unable to prepare department price update.');
}
foreach ($normalizedPrices as $productId => $price) {
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$deleteStatement->bind_param('ii', $departmentId, $productId);
$deleteStatement->execute();
$insertStatement->bind_param('iii', $departmentId, $productId, $price);
$insertStatement->execute();
}
$statement->close();
$deleteStatement->close();
$insertStatement->close();
$mysqli->commit();
} catch (\Throwable $throwable) {
$mysqli->rollback();
@@ -552,7 +865,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(
@@ -577,15 +890,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)) {
@@ -619,6 +924,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>
@@ -672,7 +1043,7 @@ class limited_backoffice_service
try {
if ($active) {
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($roleKey, $newDepartmentIds));
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
}
$userUpdates = [];
@@ -745,6 +1116,47 @@ class limited_backoffice_service
return $this->updateEmployee($manager, $employeeId, ['active' => false]);
}
/**
* @return array{employee_id:int,login_path:string}
*/
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
{
$this->assertNotSelfEdit($manager, $employeeId);
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Managed employee not found.', 404);
}
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
$this->assertDepartmentSubset($manager, $departmentIds);
$this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
}
$token = (new authentication())->create_employee_token($employeeId);
try {
(new logs_o())->add(
'auth',
'global',
1,
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
'Created limited backoffice login link for employee: ' . $employeeId
);
} catch (\Throwable) {
// Audit logging should not block login-link generation.
}
return [
'employee_id' => $employeeId,
'login_path' => '/login/qr?token=' . $token,
];
}
private function mysqli(): mysqli
{
global $db;
@@ -789,13 +1201,13 @@ class limited_backoffice_service
}
/**
* @return array{id:int,name:string,description:string}
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function fetchDepartment(int $departmentId): ?array
{
global $db;
$statement = $this->mysqli()->prepare(
'SELECT `id`, `name`, `description` FROM `departments` WHERE `id` = ? LIMIT 1'
'SELECT `id`, `name`, `description`, `custom_pricing_only` FROM `departments` WHERE `id` = ? LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to load department.', 500);
@@ -814,13 +1226,14 @@ class limited_backoffice_service
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{categories:array<int,array<string,mixed>>,missing_products:array<int,array<string,mixed>>,required_product_ids:array<int,int>}
*/
private function departmentProductCatalog(int $departmentId): array
private function departmentProductCatalog(int $departmentId, bool $customPricingOnly = false): array
{
global $db;
@@ -846,8 +1259,14 @@ class limited_backoffice_service
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`
ON pdp.`id` = (
SELECT pdp_latest.`id`
FROM `product_department_prices` pdp_latest
WHERE pdp_latest.`department_id` = dc.`department_id`
AND pdp_latest.`product_id` = p.`id`
ORDER BY pdp_latest.`id` DESC
LIMIT 1
)
WHERE ' . implode(' AND ', $where) . '
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC'
);
@@ -863,9 +1282,15 @@ class limited_backoffice_service
$categories = [];
$missing = [];
$requiredProductIds = [];
$seenProductIds = [];
foreach ($rows as $row) {
$categoryId = (int)$row['category_id'];
$productId = (int)$row['product_id'];
if (isset($seenProductIds[$productId])) {
continue;
}
$seenProductIds[$productId] = true;
$requiredProductIds[] = $productId;
if (!isset($categories[$categoryId])) {
@@ -881,10 +1306,12 @@ class limited_backoffice_service
'id' => $productId,
'name' => (string)$row['product_name'],
'description' => (string)($row['product_description'] ?? ''),
'price' => $row['department_price'] === null ? null : (int)$row['department_price'],
'price' => $row['department_price'] === null
? ($customPricingOnly ? products_o::CUSTOM_PRICING_MISSING_PRICE : null)
: (int)$row['department_price'],
];
if ($row['department_price_id'] === null) {
if ($row['department_price_id'] === null && !$customPricingOnly) {
$missing[] = [
'id' => $productId,
'name' => (string)$row['product_name'],
@@ -903,7 +1330,7 @@ class limited_backoffice_service
return [
'categories' => array_values($categories),
'missing_products' => $missing,
'required_product_ids' => array_values(array_unique($requiredProductIds)),
'required_product_ids' => array_values($requiredProductIds),
];
}
@@ -1212,18 +1639,57 @@ class limited_backoffice_service
$groupId = (int)$this->mysqli()->insert_id;
$statement->close();
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($roleKey, $departmentIds));
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $departmentIds));
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>
*/
private function effectiveRolePermissionsForManager(users_o $manager, string $roleKey, bool $includePublicVisibility): array
{
$permissions = $includePublicVisibility ? self::MANAGED_EMPLOYEE_BASE_PERMISSIONS : ['user'];
foreach (self::ROLE_PRESETS[$roleKey]['permissions'] ?? [] as $permission) {
if (in_array($permission, self::MANAGED_EMPLOYEE_BASE_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if (in_array($permission, self::ROLE_UNCONDITIONAL_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if ($manager->hasPermission($permission)) {
$permissions[] = $permission;
}
}
return array_values(array_unique($permissions));
}
/**
* @param array<int, int> $departmentIds
* @return array<int, string>
*/
private function permissionsForRoleAndDepartments(string $roleKey, array $departmentIds): array
private function permissionsForRoleAndDepartments(users_o $manager, string $roleKey, array $departmentIds): array
{
$permissions = self::ROLE_PRESETS[$roleKey]['permissions'] ?? [];
$permissions = $this->effectiveRolePermissionsForManager($manager, $roleKey, true);
foreach ($departmentIds as $departmentId) {
$permissions[] = 'department_access_' . $departmentId;
}
@@ -1265,29 +1731,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
*/
@@ -1325,6 +1768,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
*/
@@ -1342,6 +1821,7 @@ class limited_backoffice_service
{
return [
'id' => (int)$row['user_id'],
'user_id' => (int)$row['user_id'],
'customer_number' => (int)$row['customer_number'],
'display_name' => (string)($row['display_name'] ?? ''),
'email' => $row['email'] === null ? null : (string)$row['email'],
@@ -1418,6 +1898,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) {
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for customer product price overrides.
*/
class price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureColumns(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'price_overrides')) {
return;
}
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
$db->query(
"ALTER TABLE price_overrides
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER percentage"
);
}
self::$initialized = true;
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function escapeIdentifier(string $value): string
{
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
}
}
@@ -499,6 +499,7 @@ class system_search_document_index
*/
private function buildCustomerDiscountDocuments(): array
{
price_overrides_schema_bootstrap::ensureColumns();
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
$selectFields = [
'po.id AS entity_id',
@@ -506,6 +507,7 @@ class system_search_document_index
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'u.customer_number',
'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'),
@@ -554,6 +556,7 @@ class system_search_document_index
$row['search_text'] ?? null,
$row['product_or_category_id'] ?? null,
$row['percentage'] ?? null,
$row['fixed_price'] ?? null,
$row['user_id'] ?? null,
]),
$this->toIntOrNull($row['customer_number'] ?? null),
@@ -564,6 +567,7 @@ class system_search_document_index
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? null,
@@ -1086,6 +1086,7 @@ class system_search_service
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
{
price_overrides_schema_bootstrap::ensureColumns();
$customerFilter = '';
if (!empty($forcedCustomerNumbers)) {
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
@@ -1100,11 +1101,12 @@ class system_search_service
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'u.customer_number',
'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'),
];
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
@@ -1166,6 +1168,7 @@ class system_search_service
'search_text',
'product_or_category_id',
'percentage',
'fixed_price',
'user_id',
], $terms) + $entityBoost,
'payload' => $this->augmentPayloadWithTemporal([
@@ -1173,6 +1176,7 @@ class system_search_service
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
], $row),
@@ -1246,19 +1246,20 @@ class xlvask_automation_service
{
global $db;
(new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = [
'FinishStatus = 1',
'(ignored_at IS NULL OR ignored_at = "")',
];
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
} else {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
}
if ($dateTo !== null && strtotime($dateTo) !== false) {
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
}
$limit = max(1, min(500, $limit));
@@ -14,6 +14,8 @@ class economic_customer_mo
public null|string $message;
public null|string $corporateIdentificationNumber;
public null|string $email;
public null|string $ean;
public null|string $publicEntryNumber;
public null|string $mobilePhone;
public null|string $currency;
public null|string $country;
@@ -46,6 +48,8 @@ class economic_customer_mo
$this->zip = ($customer->zip ?? null);
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
$this->email = ($customer->email ?? null);
$this->ean = ($customer->ean ?? null);
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
$this->mobilePhone = ($customer->mobilePhone ?? null);
$this->currency = ($customer->currency ?? null);
$this->country = ($customer->country ?? null);
@@ -100,6 +104,8 @@ class economic_customer_mo
'zip' => $this->zip,
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
'email' => $this->email,
'ean' => $this->ean,
'publicEntryNumber' => $this->publicEntryNumber,
'mobilePhone' => $this->mobilePhone,
'currency' => $this->currency,
'country' => $this->country,
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
$customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt';
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number;
}
// Send the request
$response = $this->send_request(
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
'currency' => $customer->getCurrency() ?? 'DKK',
// Set the recipient details
'recipient' => [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
],
'recipient' => $recipient,
])
);
// Return the response as an object
@@ -194,4 +203,4 @@ class economic_invoices_drafts_endpoint
}
}
}
}
@@ -149,6 +149,29 @@ class economic_customer
return $this->customer_data_object->email;
}
public function getEan(): ?string
{
self::requireSelected();
return $this->nullableStringField('ean');
}
public function getPublicEntryNumber(): ?string
{
self::requireSelected();
return $this->nullableStringField('publicEntryNumber');
}
protected function nullableStringField(string $field): ?string
{
$value = $this->customer_data_object->{$field} ?? null;
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/**
* Get the customer address
* @return string The customer address
@@ -227,4 +250,4 @@ class economic_customer
return $this->customer_data_object->vatZone->vatZoneNumber;
}
}
}
@@ -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
@@ -22,6 +22,7 @@ class departments_o extends db
public object_property $dimension; // The dimension of the department
public object_property $visible; // The visibility of the department
public object_property $archived; // Whether the department is archived
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
public object_property $branding; // The branding of the department
public object_property $longitude; // The longitude of the department (Can be null)
public object_property $latitude; // The latitude of the department (Can be null)
@@ -107,6 +108,7 @@ class departments_o extends db
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
@@ -185,6 +187,12 @@ class departments_o extends db
return $department;
}
public function isCustomPricingOnly(int $department_id): bool
{
$department = $this->getDepartmentById($department_id);
return (bool)(int)($department['custom_pricing_only'] ?? 0);
}
/**
* Get the price of a product in a department
* @param int $department_id
+10 -6
View File
@@ -3,6 +3,7 @@
namespace objects;
use classes\db;
use classes\customer_order_product_policy;
use classes\object_property;
use Exception;
use traits\db_object_t;
@@ -93,6 +94,7 @@ class order_items_o extends db
{
global $db, $response;
try {
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
// Avoid SQL injection
$reference = $db->escape_string($reference);
$notes = $db->escape_string($notes);
@@ -167,18 +169,20 @@ class order_items_o extends db
try {
// Get the order
$order = (new orders_o())->getOrderById($order_id);
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
// Get the product price
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
$product = (new products_o())->getProductById($product_id);
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
$price = $priceResolution['price'];
// Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id);
$discount = $customer->getCustomPrice($product_id, false);
if ($discount) {
$price = $price - ($price * $discount / 100);
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
}
// If the price is forced, set the price to the forced price
if ($forcePrice) {
if ($forcePrice !== null) {
$price = (int)$forcePrice;
}
@@ -354,4 +358,4 @@ class order_items_o extends db
{
return (new products_o())->select((int)$this->product_id->value());
}
}
}
+17 -12
View File
@@ -1428,15 +1428,16 @@ class orders_o extends db
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
$order_item->reference->set('');
// Get the product price based on the department
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$product_price = (int)$priceResolution['price']; // Get the department price for the product
// Get the customers custom price discount percentage
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
if (!$user->exists()) {
throw new Exception('No user found matching the customer number in the usage log');
}
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price);
}
$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
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
@@ -1503,11 +1504,12 @@ class orders_o extends db
if (!$current_user->exists()) {
throw new Exception('No current user found');
}
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
// Apply the discount percentage to the product price
return (int)round($price * (1 - ($discount_percentage / 100)));
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$price = (int)$priceResolution['price']; // Get the department price for the product
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price;
}
return $current_user->applyProductCustomerPricing((int)$product->id, $price);
}
/**
@@ -1589,13 +1591,16 @@ class orders_o extends db
$product_id = (int)$item['product_id'];
if (!isset($department_price_cache[$product_id])) {
$product = (new products_o())->select($product_id);
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
}
if ($tmp_user === null) {
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
}
$discount = $tmp_user->getCustomPrice($product_id, false);
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
$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);
}
$post_discount = $unitPrice * $quantity;
$total += $post_discount;
}
+63 -13
View File
@@ -13,6 +13,11 @@ class products_o extends db
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
public const PRICE_SOURCE_DEPARTMENT = 'department';
public const PRICE_SOURCE_DEFAULT = 'default';
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
public const PRICE_SOURCE_KEY = '_department_price_source';
/**
* The name of the product
@@ -255,24 +260,41 @@ class products_o extends db
* @param int $department_id
* @return array
*/
public function applyDepartmentPricing(array $products, int $department_id): array
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
{
global $db;
$department_id = $db->escape_string($department_id);
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
$result = $db->query($sql);
$prices = $db->fetch_all($result);
$priceLookup = [];
foreach ($prices as $price) {
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
}
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
foreach ( $products as $key => $product ) {
foreach ( $prices as $price ) {
if ((int)$product['id'] === (int)$price['product_id']) {
$products[$key]['price'] = $price['price'];
}
$productId = (int)($product['id'] ?? 0);
$source = self::PRICE_SOURCE_DEFAULT;
if (array_key_exists($productId, $priceLookup)) {
$products[$key]['price'] = $priceLookup[$productId];
$source = self::PRICE_SOURCE_DEPARTMENT;
} elseif ($customPricingOnly) {
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
}
if ($includePriceSource) {
$products[$key][self::PRICE_SOURCE_KEY] = $source;
}
}
return $products;
}
public function getDepartmentPrice(int $department_id): int
/**
* @return array{price:int,source:string}
*/
public function getDepartmentPriceResolution(int $department_id): array
{
global $db;
$department_id = $db->escape_string($department_id);
@@ -281,10 +303,27 @@ class products_o extends db
$prices = $db->fetch_all($result);
// Check if the product has a department price
if (count($prices) > 0) {
return $prices[0]['price'];
return [
'price' => (int)$prices[0]['price'],
'source' => self::PRICE_SOURCE_DEPARTMENT,
];
}
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
return [
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
];
}
// Return the default price
return $this->price->value();
return [
'price' => (int)$this->price->value(),
'source' => self::PRICE_SOURCE_DEFAULT,
];
}
public function getDepartmentPrice(int $department_id): int
{
return $this->getDepartmentPriceResolution($department_id)['price'];
}
public function applyCustomerDiscounts(array $products, users_o $customer): array
@@ -300,15 +339,26 @@ class products_o extends db
if (!isset($product['id']) || !isset($product['price'])) {
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
}
// Get the customer's discount percentage
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
// Apply the discount to the product price
if ($discount_percentage > 0) {
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
}
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}
public static function stripDepartmentPriceSources(array $products): array
{
return array_map(static function (array $product): array {
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}, $products);
}
public static function priceResolutionIsCustomMissing(array $resolution): bool
{
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
}
public function getSubscriptionMonthlyPrice(): int
{
// Subscription price (for 2 washes per month) is 1.2 times the normal price
@@ -4,6 +4,8 @@ namespace objects;
use classes\db;
use classes\object_property;
use classes\price_overrides_schema_bootstrap;
use classes\system_search_cache;
use traits\db_object_t;
class user_price_overrides_o extends db
@@ -14,10 +16,12 @@ class user_price_overrides_o extends db
public object_property $is_category;
public object_property $product_or_category_id;
public object_property $percentage;
public object_property $fixed_price;
public function structure(): void
{
$this->setTable('price_overrides');
price_overrides_schema_bootstrap::ensureColumns();
}
public function objectChanged(): void
@@ -30,6 +34,7 @@ class user_price_overrides_o extends db
$this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true);
$this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true);
$this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true);
$this->fixed_price = new object_property($this->table, $this->id, 'fixed_price', 'int', false, null);
}
public function setUser($user_id): user_price_overrides_o
@@ -43,39 +48,41 @@ class user_price_overrides_o extends db
* @param bool $is_category
* @param int|string $product_or_category_id
* @param int $percentage
* @param int|null $fixed_price
* @return $this
*/
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage): user_price_overrides_o
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage, ?int $fixed_price = null): user_price_overrides_o
{
global $db;
// If the user is not set, return the object
if (!isset($this->user_id)) {
return $this;
}
if ($is_category) {
$fixed_price = null;
}
// Check if the record already exists
$this->removePriceIfExist($is_category, $product_or_category_id);
// If the percentage is 0, return the object
if ($percentage === 0) {
// If neither a discount nor a fixed product price is set, remove the record.
if ($percentage === 0 && $fixed_price === null) {
return $this;
}
// Create a new record in the database
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage) VALUES ($this->user_id, " . (int)$is_category . ", '$product_or_category_id', $percentage)";
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$fixed_price_sql = $fixed_price === null ? 'NULL' : (string)max(0, (int)$fixed_price);
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage, fixed_price) VALUES (" . (int)$this->user_id . ", " . (int)$is_category . ", '$product_or_category_id', " . (int)$percentage . ", $fixed_price_sql)";
$db->query($sql);
$this->markSearchDirty();
return $this;
}
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
{
global $db;
// Get the price override from the database
$sql = "SELECT * FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$result = $db->query($sql);
if ($result->num_rows > 0) {
// Remove the record from the database
$sql = "DELETE FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$db->query($sql);
}
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$sql = "DELETE FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$db->query($sql);
$this->markSearchDirty();
}
/**
@@ -132,6 +139,49 @@ class user_price_overrides_o extends db
return $percentage;
}
public function getFixedPrice(bool $is_category, int|string $product_or_category_id): ?int
{
if ($is_category || !isset($this->user_id)) {
return null;
}
$row = $this->getDirectPriceRow(false, (int)$product_or_category_id);
if ($row === null || $row['fixed_price'] === null) {
return null;
}
return (int)$row['fixed_price'];
}
public function getDirectPriceRow(bool $is_category, int|string $product_or_category_id): ?array
{
global $db;
if (!isset($this->user_id)) {
return null;
}
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$sql = "SELECT * FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id' LIMIT 1";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
$row['id'] = (int)$row['id'];
$row['user_id'] = (int)$row['user_id'];
$row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = $is_category
? (string)$row['product_or_category_id']
: (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage'];
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null
? (int)$row['fixed_price']
: null;
return $row;
}
/**
* Get all the price overrides for the user
* @return array
@@ -153,6 +203,7 @@ class user_price_overrides_o extends db
$row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage'];
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null;
$row['created_at'] = (string)$row['created_at'];
$row['updated_at'] = (string)$row['updated_at'];
// Add the row to the list
@@ -167,10 +218,19 @@ class user_price_overrides_o extends db
'is_category' => true,
'product_or_category_id' => "global",
'percentage' => (int)$economic_user_global_discount,
'fixed_price' => null,
'created_at' => "2021-01-01 00:00:00",
'updated_at' => "2021-01-01 00:00:00"
];
}
return $prices;
}
}
private function markSearchDirty(): void
{
try {
system_search_cache::markDirtyTable($this->table);
} catch (\Throwable) {
}
}
}
+84 -2
View File
@@ -981,6 +981,31 @@ class users_o extends db
return $discount_percentage === null ? 0 : (int)$discount_percentage;
}
public function getProductFixedPrice(int $product_id): ?int
{
self::requireSelected();
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
{
self::requireSelected();
$fixed_price = $this->getProductFixedPrice($product_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);
if ($discount_percentage <= 0) {
return $base_price;
}
return (int)round($base_price * (1 - ($discount_percentage / 100)));
}
/**
@@ -1077,9 +1102,65 @@ class users_o extends db
return $tmp;
}
/**
* @param array<int, array<string, mixed>> $users
* @return array<int, array<string, mixed>>
*/
public function markLimitedBackofficeManagedUsers(array $users): array
{
$userIds = [];
foreach ($users as $user) {
$userId = (int)($user['id'] ?? 0);
if ($userId > 0) {
$userIds[$userId] = true;
}
}
if ($userIds === []) {
return $users;
}
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` IN (' .
implode(',', array_map('intval', array_keys($userIds))) .
')'
));
$managedUserIds = [];
foreach ($rows as $row) {
$managedUserIds[(int)$row['user_id']] = true;
}
foreach ($users as $key => $user) {
$users[$key]['limited_backoffice_managed'] = isset($managedUserIds[(int)($user['id'] ?? 0)]);
}
return $users;
}
public function isLimitedBackofficeManagedUser(int $userId): bool
{
if ($userId <= 0) {
return false;
}
global $db;
$result = $db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$userId . ' LIMIT 1'
);
return $result !== false && $result->num_rows > 0;
}
public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
{
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) {
if ((bool)($value['limited_backoffice_managed'] ?? false) || (int)($value['customer_number'] ?? -1) === 0) {
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $value['display_name'] ?? null;
continue;
}
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
}
return $listObjectsWithPaginationIfSet;
@@ -1183,15 +1264,16 @@ class users_o extends db
* @param int $object_id The ID of the object
* @param int $discount_percentage The discount percentage
* @param bool $is_category If the object is a category
* @param int|null $fixed_price The fixed product price, when set
* @return void
*/
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false): void
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false, ?int $fixed_price = null): void
{
$this->id = $user_id;
// Get the user object properties
$this->getObjectProperties();
// Set the custom price (key = 'custom_price')
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage);
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage, $fixed_price);
}
public function syncAllUsersEconomicCustomerDetails(): void
@@ -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')
+103 -5
View File
@@ -2595,6 +2595,12 @@ paths:
type: string
description: Contact person name
example: "Mikkel"
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
g_recaptcha_response:
type: string
description: reCAPTCHA verification token
@@ -3092,7 +3098,7 @@ paths:
get:
tags:
- Users
summary: Get user discounts
summary: Get user discounts and product fixed prices
operationId: getUserDiscounts
parameters:
- name: user_id
@@ -3108,7 +3114,7 @@ paths:
post:
tags:
- Users
summary: Set user discount
summary: Set user discount or product fixed price
operationId: setUserDiscount
requestBody:
required: true
@@ -3122,6 +3128,11 @@ paths:
discount: {type: integer}
object_id: {type: string}
is_category: {type: boolean}
fixed_price:
type: integer
nullable: true
minimum: 0
description: Optional product-only fixed price. Omit to preserve the current fixed price, send null to clear it.
responses:
'200':
description: Success
@@ -8653,6 +8664,12 @@ paths:
email: {type: string}
phone: {type: integer}
name: {type: string}
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
responses:
'200':
description: Success
@@ -12405,6 +12422,29 @@ paths:
application/json:
schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions:
post:
tags:
@@ -12751,6 +12791,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -13422,7 +13489,6 @@ components:
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
@@ -13438,10 +13504,11 @@ components:
description: What actually happened
data_collection_accepted:
type: boolean
description: Required acceptance of collecting screenshot and diagnostic error data
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
screenshot:
type: string
description: PNG, JPEG, or WebP data URI of the current app viewport
nullable: true
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
route_path:
type: string
nullable: true
@@ -13552,6 +13619,7 @@ components:
nullable: true
screenshot:
type: object
nullable: true
additionalProperties: true
answers:
type: object
@@ -21559,6 +21627,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
@@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute
$product_cache[$product_id] = (new products_o())->select($product_id);
}
if (!isset($department_price_cache[$department_id][$product_id])) {
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
}
if (!array_key_exists($product_id, $discount_cache)) {
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
$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);
}
$discount_cache[$product_id] = $unit_price;
}
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
$post_discount = (int)$discount_cache[$product_id] * $quantity;
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
}
+9
View File
@@ -427,6 +427,7 @@ class authRoute
$contactEmail = self::getParameter('contactEmail');
$contactPhone = (int)self::getParameter('contactPhone');
$contactName = self::getParameter('contactName');
$ean = null;
/**
* Validate
*/
@@ -454,6 +455,13 @@ class authRoute
self::requireMinValue($contactPhone, 10000000);
self::requireMaxValue($contactPhone, 9999999999);
}
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
/**
* If the contact phone is empty, default to company phone
@@ -545,6 +553,7 @@ class authRoute
(int)$companyPhone,
(int)$contactPhone,
$companyInformation,
$ean,
);
} catch (Exception $exception) {
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
@@ -13,6 +13,7 @@ use DateTimeZone;
use Exception;
use objects\department_daily_report_complaints_o;
use objects\department_daily_reports_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
use objects\users_o;
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
{
use route_t;
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
public function run(): void
{
$this->get('/departments/daily-reports', function () {
@@ -850,7 +853,8 @@ class departmentDailyReportsRoute
'overview' => $this->buildDailyReportOverview(
[$department_id],
(string)self::getParameter('date'),
$date_to
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
),
]);
},
@@ -895,7 +899,8 @@ class departmentDailyReportsRoute
$this->buildDailyReportOverview(
$department_ids,
(string)self::getParameter('date'),
$date_to
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
)
);
},
@@ -906,6 +911,73 @@ class departmentDailyReportsRoute
]
);
$this->put('/departments/daily-reports/product-targets', function () {
global $response;
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
'department_id',
'product_id',
'target_percentage',
]);
$department_id = (int)self::getParameter('department_id');
if ($department_id <= 0) {
$response->error('Parameter department_id must be a positive integer', 400);
return;
}
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
self::requireDepartmentAccess($department_id);
$product_id = (int)self::getParameter('product_id');
if (!$this->isDailyReportProductId($product_id)) {
$response->error('Invalid daily report product_id', 400);
return;
}
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
if (!$parsed_target['valid']) {
$response->error($parsed_target['message'], 400);
return;
}
$target_percentage = $parsed_target['value'];
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$target_key = $this->dailyReportProductTargetVariableKey($product_id);
if ($target_percentage === null) {
$this->clearDailyReportProductTarget($department_variables, $target_key);
} else {
$department_variables->set($target_key, number_format($target_percentage, 1, '.', ''));
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'Successfully updated department daily report product target');
$response->success([
'department_id' => $department_id,
'product_id' => $product_id,
'target_percentage' => $target_percentage,
]);
},
[
self::SET_PRODUCT_TARGET_PERMISSION => 'Set department daily report product target percentages',
'department_access_:department_id' => 'Access the department'
]
);
$this->get('/departments/daily-reports/product-count', function () {
// Require the user to be logged in
global $response;
@@ -1369,7 +1441,7 @@ class departmentDailyReportsRoute
* }
* @throws Exception
*/
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to): array
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to, bool $include_product_targets = false): array
{
$repository = $this->dailyReportRepository();
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
@@ -1389,6 +1461,11 @@ class departmentDailyReportsRoute
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to);
$product_target_lookup = [];
if ($include_product_targets && count($department_ids) === 1) {
$product_target_lookup = $this->getDailyReportProductTargetsForDepartment((int)$department_ids[0], $product_definitions);
}
return $this->assembleDailyReportOverview(
$department_ids,
$date,
@@ -1399,7 +1476,8 @@ class departmentDailyReportsRoute
$product_summary_lookup,
$complaints_metric,
$night_wash_metric,
$overtime_metric
$overtime_metric,
$product_target_lookup
);
}
@@ -1412,6 +1490,7 @@ class departmentDailyReportsRoute
* @param array<string,mixed> $complaints_metric
* @param array<string,mixed> $night_wash_metric
* @param array<string,mixed> $overtime_metric
* @param array<int,float> $product_target_lookup
* @return array{
* department_ids:array<int>,
* date:string,
@@ -1430,7 +1509,8 @@ class departmentDailyReportsRoute
array $product_summary_lookup,
array $complaints_metric,
array $night_wash_metric,
array $overtime_metric
array $overtime_metric,
array $product_target_lookup = []
): array {
$products = [];
foreach ($product_definitions as $definition) {
@@ -1448,6 +1528,12 @@ class departmentDailyReportsRoute
'state' => 'ready',
'value' => (int)($product_summary['quantity'] ?? 0),
'out_of' => (int)($product_summary['out_of'] ?? 0),
'target_percentage' => array_key_exists($product_id, $product_target_lookup)
? (float)$product_target_lookup[$product_id]
: null,
'target_department_id' => array_key_exists($product_id, $product_target_lookup)
? (int)$department_ids[0]
: null,
];
}
@@ -1506,6 +1592,87 @@ class departmentDailyReportsRoute
return array_values($normalized);
}
private function isDailyReportProductId(int $product_id): bool
{
return in_array(
$product_id,
array_map(static fn(array $definition): int => (int)$definition['product_id'], $this->getDailyReportProductDefinitions()),
true
);
}
/**
* @return array{valid:bool,value:?float,message:string}
*/
private function parseDailyReportProductTargetPercentage(mixed $target_percentage): array
{
if ($target_percentage === null) {
return ['valid' => true, 'value' => null, 'message' => ''];
}
if (is_string($target_percentage)) {
$target_percentage = trim($target_percentage);
if ($target_percentage === '') {
return ['valid' => true, 'value' => null, 'message' => ''];
}
}
if (!is_int($target_percentage) && !is_float($target_percentage) && !(is_string($target_percentage) && is_numeric($target_percentage))) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be numeric, null, or empty'];
}
$target_percentage = round((float)$target_percentage, 1);
if ($target_percentage < 0.0 || $target_percentage > 100.0) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be between 0 and 100'];
}
return ['valid' => true, 'value' => $target_percentage, 'message' => ''];
}
/**
* @param array<int,array{product_id:int,slug:string,title:string}> $product_definitions
* @return array<int,float>
* @throws Exception
*/
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
{
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$targets = [];
foreach ($product_definitions as $definition) {
$product_id = (int)$definition['product_id'];
$stored_target = $department_variables->getVariable($this->dailyReportProductTargetVariableKey($product_id));
if ($stored_target === null || $stored_target === '' || !is_numeric($stored_target)) {
continue;
}
$targets[$product_id] = round((float)$stored_target, 1);
}
return $targets;
}
protected function clearDailyReportProductTarget(department_variables_o $department_variables, string $target_key): void
{
$existing_targets = $department_variables->getFieldsWhere([
'department_id' => $department_variables->department_id,
'variable' => $target_key,
], ['id']);
if (!$existing_targets) {
return;
}
department_variables_o::delete_object('department_variables', (int)$existing_targets[0]['id']);
$department_variables->objectChanged();
}
private function dailyReportProductTargetVariableKey(int $product_id): string
{
return 'daily_report_product_target_percentage_' . $product_id;
}
/**
* @return array<int,array{product_id:int,slug:string,title:string}>
*/
+23 -7
View File
@@ -103,6 +103,7 @@ class departmentsRoute
'economic_department_id',
'visible',
'archived',
'custom_pricing_only',
'longitude',
'latitude',
])
@@ -123,6 +124,12 @@ class departmentsRoute
'latitude' => (float)$department['latitude'],
'order_priority' => (int)$department['order_priority'],
];
if (
$user->hasPermission('superuser_fetch_department')
|| $user->hasPermission('edit_department')
) {
$tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
}
// If the user has the permission to view the slack webhook, add it to the response
if ($user->hasPermission('view_slack_webhook')) {
$tmp_department['slack_webhook'] = $department['slack_webhook'];
@@ -220,6 +227,9 @@ class departmentsRoute
if (self::isParametersSet(['archived'])) {
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
}
if (self::isParametersSet(['custom_pricing_only'])) {
$department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only')));
}
$department->objectChanged();
// Log the incident
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
@@ -240,11 +250,17 @@ class departmentsRoute
$this->get('/departments/categories', function () {
// Require the user to be logged in
global $response;
self::requirePermission('list_department_categories');
// 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) {
$isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser;
if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) {
$this->emitForbidden(['list_department_categories']);
}
$responsibleUserId = $user ? (int)$user->id : 0;
// Require the department id
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
@@ -253,14 +269,14 @@ class departmentsRoute
// Validate the department categories object
if (!$department->exists()) {
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
// Return an error
$response->error('Department categories not found', 400);
}
// Get the department categories
$department_categories = new department_categories_o();
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
// Return the list of department categories
$response->success(
$department_categories
@@ -285,7 +301,7 @@ class departmentsRoute
}
},
[
'list_department_categories' => 'List all department categories'
'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.'
]
);
@@ -45,10 +45,10 @@ class limitedBackofficeRoute
]);
$this->get('/limited-backoffice/roles', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service): array {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->rolePresets();
return $service->rolePresets($user);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
@@ -78,6 +78,26 @@ 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);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->put('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -60,6 +60,14 @@ class moduleEconomicCustomerRoute
self::requireMaxLength('phone', 255);
self::requireMinLength('name', 1);
self::requireMaxLength('name', 255);
$ean = null;
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
(new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer');
$result = (new economic())->createCustomer(
(int)self::getParameter('customer_number'),
@@ -67,6 +75,9 @@ class moduleEconomicCustomerRoute
(int)self::getParameter('cvr'),
(string)self::getParameter('email'),
(int)self::getParameter('phone'),
null,
null,
$ean,
);
$response->success((object)$result);
} else {
@@ -76,4 +87,4 @@ class moduleEconomicCustomerRoute
});
}
}
}
@@ -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',
+46 -13
View File
@@ -41,18 +41,9 @@ class orderBookingRoute
$po = self::getTargetPo(); // String | Null
$pickup = self::getTargetPickup(); // Bool | Null
$items = self::getTargetItems(); // Array of order_items_o objects
/**
* Permissions (clean helper)
*/
$permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
$permission_other = self::definePermission('add_bookings');
self::allowOwnOrDepartmentAccess(
$permission_own,
$permission_other,
$this->requireOrderBookingCreateAccess(
(int)$customer_number->customer_number->value(),
(int)$department->id,
null,
'You do not have permission to create this order booking.'
(int)$department->id
);
/**
* Input data
@@ -96,8 +87,8 @@ class orderBookingRoute
$response->success($order_bookings_o->asArray());
},
[
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
'add_bookings' => 'Permission to create department order bookings.'
'add_bookings' => 'Permission to create order bookings for another customer or department scope.',
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'
]
);
@@ -659,6 +650,48 @@ class orderBookingRoute
return $object;
}
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
{
$auth = new authentication();
if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) {
$permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
if (!self::hasPermission($permissionOwn, $targetCustomerNumber)) {
$this->emitForbidden([$permissionOwn]);
}
return;
}
if (
$auth->get_user() !== false
&& self::hasPermission('user')
&& $this->isOwnCustomerContext($targetCustomerNumber)
) {
return;
}
$permissionOther = self::definePermission('add_bookings');
if (!self::hasPermission($permissionOther)) {
$this->emitForbidden([$permissionOther]);
}
self::requireDepartmentAccess((string)$departmentId);
}
private function isOrderBookingCustomerSession(): bool
{
try {
$auth = new authentication();
if ($auth->get_subuser() !== false) {
return true;
}
return $auth->get_user() !== false && self::hasPermission('user');
} catch (Exception) {
return false;
}
}
/**
* @throws Exception If the Department is invalid.
*/
@@ -612,21 +612,46 @@ class orderInvoicesRoute
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
);
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
$invoice_collection_ids = [];
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
if (self::isParametersSet(['invoice_collection_ids'])) {
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids_raw)) {
$response->error('invoice_collection_ids must be an array', 400);
}
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
$invoice_collection_id = (int)$invoice_collection_id_raw;
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
$invoice_collection_ids[] = $invoice_collection_id;
}
$invoice_collection_ids = array_values(array_unique($invoice_collection_ids));
if (empty($invoice_collection_ids)) {
$response->error('invoice_collection_ids must contain at least one id', 400);
}
} else {
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
$invoice_collection_ids[] = $invoice_collection_id;
}
}
}
$items = [];
+70 -10
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\customer_product_rule_service;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
@@ -70,10 +71,29 @@ class orderItemsRoute
$price = (int)self::getParameter('price');
}
}
$order = (new orders_o())->getOrderById((int)$data['order_id']);
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);
}
$customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) {
(new logs_o())->add(
'order_items',
'global',
1,
$user->id,
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
);
$response->error($customerRuleViolation['message'], 400);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
@@ -155,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
@@ -187,7 +227,7 @@ class orderItemsRoute
$this->put('/order/items', function () {
// Require the user to be logged in
global $response;
global $response, $db;
$this->requirePermission('edit_order_items');
// Get the user object
$user = (new authentication())->get_user();
@@ -212,19 +252,39 @@ class orderItemsRoute
$response->error('Quantity is required', 400);
}
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
$orderItemId = (int)$data['id'];
$orderItem = (new order_items_o())->getOrderItemById($orderItemId);
if (!$orderItem->exists()) {
$response->error('Order item not found', 404);
}
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
$orderItemContextResult = $db->query(
"SELECT oi.order_id, oi.product_id, p.name AS product_name, p.requires_note AS product_requires_note
FROM order_items oi
LEFT JOIN products p ON p.id = oi.product_id
WHERE oi.id = {$orderItemId}
LIMIT 1"
);
$orderItemContext = $orderItemContextResult ? $orderItemContextResult->fetch_assoc() : null;
if ($orderItemContext === null) {
$response->error('Order item not found', 404);
}
if ($orderItemContext['product_id'] === null || $orderItemContext['product_name'] === null) {
$response->error('Product not found', 404);
}
if (products_o::productDataRequiresOrderItemNote([
'id' => (int)$orderItemContext['product_id'],
'name' => (string)$orderItemContext['product_name'],
'requires_note' => (bool)$orderItemContext['product_requires_note'],
]) && trim((string)$data['notes']) === '') {
$response->error('Notes is required for this product', 400);
}
$order = (new orders_o())->getOrderById((int)$orderItem->order_id->value());
$order = (new orders_o())->getOrderById((int)$orderItemContext['order_id']);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$data['department_id']);
// Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Get the base64 file
$base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Delete the attachment
$order->removeAttachment((int)$attachment_id);
// Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value());
// Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
}
// Admin/department path (requires edit_order)
self::requirePermission($permission_other);
/** Departmental access */
/** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the target department
self::requireDepartmentAccess((string)(int)$data['department_id']);
$order->department_id->set((int)$data['department_id']);
}
// If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{
try {
$user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
} catch (\Throwable) {
return false;
}
+91 -42
View File
@@ -22,21 +22,23 @@ class productsRoute
*/
private function getCustomerIfProvided(): ?users_o
{
global $response;
if (self::isParametersSet(['customer_id'])) {
$customerId = (int)self::getParameter('customer_id');
try {
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
$customerId = $this->getOptionalPositiveIntParameter('customer_id');
if ($customerId === null) {
return null;
}
try {
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
return null;
}
@@ -45,12 +47,62 @@ class productsRoute
* @return int|null
*/
private function getDepartmentIdIfProvided(): ?int
{
return $this->getOptionalPositiveIntParameter('department_id');
}
private function getOptionalPositiveIntParameter(string $parameter): ?int
{
global $response;
if (self::isParametersSet(['department_id'])) {
return (int)self::getParameter('department_id');
if (!self::isParametersSet([$parameter])) {
return null;
}
return null;
$value = self::getParameter($parameter);
if ($this->isNullLikeOptionalParameter($value)) {
return null;
}
$parsed = null;
if (is_int($value)) {
$parsed = $value;
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$parsed = (int)trim($value);
} else {
$response->error('Invalid ' . $parameter, 400);
}
if ($parsed === null || $parsed <= 0) {
$response->error('Invalid ' . $parameter, 400);
}
return $parsed;
}
private function isNullLikeOptionalParameter(mixed $value): bool
{
if ($value === null) {
return true;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
}
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
{
if (!$user instanceof users_o || $departmentId === null) {
return;
}
if ($this->hasPermission('superuser_fetch_department')) {
return;
}
$this->requirePermission('department_access_' . $departmentId);
}
/**
@@ -59,11 +111,7 @@ class productsRoute
*/
private function getCategoryIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['category'])) {
return (int)self::getParameter('category');
}
return null;
return $this->getOptionalPositiveIntParameter('category');
}
/**
@@ -72,11 +120,7 @@ class productsRoute
*/
private function getProductIdIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['id'])) {
return (int)self::getParameter('id');
}
return null;
return $this->getOptionalPositiveIntParameter('id');
}
/**
@@ -91,12 +135,14 @@ class productsRoute
// Check if the departmentId is set
if ($departmentId) {
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
}
// Check if the customer is set
if ($customer !== null) {
// Apply the customers unique discounts
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
} else {
$products = products_o::stripDepartmentPriceSources($products);
}
return $products;
}
@@ -195,12 +241,14 @@ class productsRoute
// Check if the request was successful
if ($user || $isProductDetailsRestricted) {
// Define the variables
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$customer = $this->getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
// Check if the "final_price" parameter is set, and true.
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
if ($useFinalPrice) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Determine the products to return
if ($category) {
// Get products in the category
@@ -258,17 +306,17 @@ class productsRoute
);
}
// Check if the category is set in the request
$data = $_GET ?? [];
// Check if the category is set
if (isset($data['category'])) {
if ($category !== null) {
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
// Return the list of products
$products = (new products_o())->listObjectsByCategory($data['category']);
$products = (new products_o())->listObjectsByCategory($category);
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
}
$response->success(
array_map(function ($product) use ($isProductDetailsRestricted) {
@@ -279,9 +327,10 @@ class productsRoute
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Get all product ids contained in a category attached to the department
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
// Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) {
return $product->id;
@@ -296,7 +345,7 @@ class productsRoute
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
), (int)$data['department_id'])
), $departmentId)
);
}
// Return the list of products
+21 -1
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o;
use objects\logs_o;
use traits\route_t;
@@ -106,6 +107,25 @@ class rolesRoute
]
);
self::get('/roles/limited-backoffice-permission-templates', function () {
global $response;
self::requirePermission('superuser');
self::requirePermission('add_role_permission');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
} else {
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
$response->error('Invalid session', 400);
}
},
[
'superuser' => 'Access the superuser interface',
'add_role_permission' => 'Add a permission to a role'
]
);
self::post('/roles/permissions', function () {
// Require the user to be logged in
global $response;
@@ -182,4 +202,4 @@ class rolesRoute
]
);
}
}
}
+30 -2
View File
@@ -103,6 +103,9 @@ class userRoute
}
// Check if the required fields are set
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$response->error('Invalid request body', 400);
}
if (!isset($data['discount'])) {
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set');
@@ -122,14 +125,38 @@ class userRoute
$response->error('No is_category set', 400);
}
$discount = (int)$data['discount'];
if ($discount < 0 || $discount > 100) {
$response->error('Discount must be between 0 and 100', 400);
}
$is_category = (bool)$data['is_category'];
if ($is_category) {
$object_id = (string)$data['object_id'];
} else {
$object_id = (int)$data['object_id'];
}
$fixed_price_is_set = array_key_exists('fixed_price', $data);
$fixed_price = null;
if ($fixed_price_is_set) {
if ($data['fixed_price'] === null || $data['fixed_price'] === '') {
$fixed_price = null;
} else {
$fixed_price_value = filter_var($data['fixed_price'], FILTER_VALIDATE_INT);
if ($fixed_price_value === false) {
$response->error('Invalid fixed price', 400);
}
$fixed_price = (int)$fixed_price_value;
}
if ($fixed_price !== null && $fixed_price < 0) {
$response->error('Fixed price must be zero or more', 400);
}
if ($is_category && $fixed_price !== null) {
$response->error('Fixed price can only be set for products', 400);
}
} elseif (!$is_category) {
$fixed_price = $targetUser->getProductFixedPrice((int)$object_id);
}
// Set the custom price
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category);
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category, $fixed_price);
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$targetUser->id,
@@ -145,7 +172,8 @@ class userRoute
'route' => '/superuser/user/discounts',
'method' => 'POST',
'actor_user_id' => (int)$user->id,
]
],
$fixed_price
);
} catch (\Throwable $e) {
(new logs_o())->add(
+93 -12
View File
@@ -27,19 +27,28 @@ class usersRoute
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
// Return the list of users
$users_o = new users_o();
$response->success(
$users_o->parseUsers(
$users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'customer_number',
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet()
)
$limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
$users = $users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'customer_number',
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet(
null,
$limitedEmployeeListMode['filters'],
[],
$limitedEmployeeListMode['additional_where']
);
if ($limitedEmployeeListMode['enabled']) {
$users = $users_o->markLimitedBackofficeManagedUsers($users);
}
$users = $users_o->parseUsers(
$users
);
$response->success($users);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session');
@@ -153,6 +162,23 @@ class usersRoute
if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
$data['display_name'] = null;
}
$targetUser = (new users_o())->getUserById((int)$data['id']);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) {
$currentCustomerNumber = (string)$targetUser->customer_number->value();
if ((string)$data['customer_number'] !== $currentCustomerNumber) {
$response->error('Limited backoffice managed users cannot change customer number.', 403);
}
if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) {
$response->error('Limited backoffice managed users cannot change role.', 403);
}
$data['role'] = null;
}
// If the role is set, require the edit_user_role permission
if ($data['role']) {
$this->requirePermission('edit_user_role');
@@ -207,4 +233,59 @@ class usersRoute
]
);
}
/**
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
*/
private function limitedBackofficeEmployeeListMode(users_o $users): array
{
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if ($filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
'additional_where' => null,
];
}
$filterArray = $users->filter_string_to_array($filters);
$customerNumberFilter = $filterArray['customer_number'] ?? null;
$isEmployeeFilter = $customerNumberFilter === '0'
|| $customerNumberFilter === 0
|| (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' => $enabled ? $filters : null,
'additional_where' => null,
];
}
// $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),
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
];
}
}
@@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` (
`is_category` TINYINT(1) NOT NULL,
`object_id` VARCHAR(64) NOT NULL,
`discount` INT NOT NULL,
`fixed_price` INT NULL DEFAULT NULL,
`effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
@@ -85,6 +85,65 @@ it('previews monthly split changes without moving orders or creating collections
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
});
it('previews only explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'preview-scope');
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Preview Monthly Split Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$ignoredCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$targetMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-03-15 10:00:00',
]);
$targetAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-04-02 10:00:00',
]);
$ignoredMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-03-16 10:00:00',
]);
$ignoredAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-04-03 10:00:00',
]);
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
$response = api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [$targetCollection['id']],
'preview' => true,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
expect($payload['processed_count'] ?? null)->toBe(1)
->and($payload['changed_count'] ?? null)->toBe(1)
->and($payload['changed'][0]['invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
});
it('splits a selected March and April collected invoice into monthly collections', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
@@ -139,6 +198,74 @@ it('splits a selected March and April collected invoice into monthly collections
}
});
it('splits only explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'scope');
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
'created_at' => '2096-03-01 00:00:01',
]);
$ignoredCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
'created_at' => '2096-03-01 00:00:01',
]);
$targetMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-03-15 10:00:00',
]);
$targetAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-04-02 10:00:00',
]);
$ignoredMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-03-16 10:00:00',
]);
$ignoredAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-04-03 10:00:00',
]);
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
$createdCollectionIds = [];
try {
$response = api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [$targetCollection['id']],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
$createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []);
$aprilCollectionId = (int)($createdCollectionIds[0] ?? 0);
expect($payload['processed_count'] ?? null)->toBe(1)
->and($payload['changed_count'] ?? null)->toBe(1)
->and($aprilCollectionId)->toBeGreaterThan(0)
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId)
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
} finally {
monthly_split_cleanup_collections($createdCollectionIds);
}
});
it('sets closed_at to month end when split month has ended', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
@@ -345,3 +472,27 @@ it('rejects invalid monthly split date ranges', function (): void {
->assertEnvelope()
->assertSuccess(false);
});
it('rejects invalid explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'invalid-scope');
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => ['not-a-number'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
usesApiSuite();
function daily_report_product_from_overview(array $overview, int $productId): array
{
foreach (($overview['products'] ?? []) as $product) {
if ((int)($product['product_id'] ?? 0) === $productId) {
return $product;
}
}
return [];
}
it('stores clears and permission-gates department daily report product targets', function (): void {
api_test_covers('PUT /departments/daily-reports/product-targets', 'happy');
api_test_covers('GET /departments/daily-reports/overview', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Daily Report Product Target ' . uniqid('', false),
]);
$departmentId = (int)$department['id'];
$editorPermissions = [
'list_department_daily_reports',
'list_bookings',
'set_department_daily_report_product_targets',
'department_access_' . $departmentId,
];
$editorSession = api_fixtures()->createUserSession($editorPermissions);
$viewerSession = api_fixtures()->createUserSession([
'list_department_daily_reports',
'list_bookings',
'department_access_' . $departmentId,
]);
try {
$saveResponse = api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 47.55,
], $editorSession['headers']);
$saveResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($saveResponse->data())->toMatchArray([
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 47.6,
]);
$overviewResponse = api_client()->get(
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
$editorSession['headers']
);
$overviewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$editorProduct = daily_report_product_from_overview($overviewResponse->data(), 24);
expect($editorProduct['target_percentage'])->toBe(47.6);
expect($editorProduct['target_department_id'])->toBe($departmentId);
$viewerOverviewResponse = api_client()->get(
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
$viewerSession['headers']
);
$viewerOverviewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$viewerProduct = daily_report_product_from_overview($viewerOverviewResponse->data(), 24);
expect($viewerProduct['target_percentage'])->toBeNull();
expect($viewerProduct['target_department_id'])->toBeNull();
$clearResponse = api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => null,
], $editorSession['headers']);
$clearResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($clearResponse->data())->toMatchArray([
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => null,
]);
$clearedOverviewResponse = api_client()->get(
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
$editorSession['headers']
);
$clearedProduct = daily_report_product_from_overview($clearedOverviewResponse->data(), 24);
expect($clearedProduct['target_percentage'])->toBeNull();
expect($clearedProduct['target_department_id'])->toBeNull();
} finally {
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => null,
], $editorSession['headers']);
}
});
it('rejects product target updates without permission access or valid input', function (): void {
api_test_covers('PUT /departments/daily-reports/product-targets', 'auth');
api_test_covers('PUT /departments/daily-reports/product-targets', 'failure');
$department = api_fixtures()->createDepartment();
$departmentId = (int)$department['id'];
$missingPermissionSession = api_fixtures()->createUserSession([
'department_access_' . $departmentId,
]);
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 50,
], $missingPermissionSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['set_department_daily_report_product_targets']);
$missingDepartmentAccessSession = api_fixtures()->createUserSession([
'set_department_daily_report_product_targets',
]);
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 50,
], $missingDepartmentAccessSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . $departmentId]);
$editorSession = api_fixtures()->createUserSession([
'set_department_daily_report_product_targets',
'department_access_' . $departmentId,
]);
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 999999,
'target_percentage' => 50,
], $editorSession['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid daily report product_id');
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 101,
], $editorSession['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Parameter target_percentage must be between 0 and 100');
});
@@ -231,6 +231,7 @@ it('updates departments through the real endpoint', function (): void {
'description' => 'Updated description',
'order_priority' => 5,
'archived' => true,
'custom_pricing_only' => true,
], $session['headers']);
$response
@@ -246,6 +247,7 @@ it('updates departments through the real endpoint', function (): void {
expect($row['description'] ?? null)->toBe('Updated description');
expect((int)($row['order_priority'] ?? 0))->toBe(5);
expect((int)($row['archived'] ?? 0))->toBe(1);
expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1);
});
it('rejects invalid department update requests', function (): void {
@@ -299,6 +301,42 @@ it('lists department categories for a department', function (): void {
->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']);
});
it('lets customer booking sessions list department categories without the management permission', function (): void {
api_test_covers('GET /departments/categories', 'auth');
$customerSession = api_fixtures()->createUserSession(['user']);
$department = api_fixtures()->createDepartment();
$category = api_fixtures()->createCategory([
'name' => 'Customer Department Category',
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
$customerResponse = api_client()->get('/departments/categories?id=' . $department['id'], $customerSession['headers']);
$customerResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($customerResponse->data())
->toBeArray()
->toHaveCount(1)
->and($customerResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
$subuserSession = api_fixtures()->createSubuserSession((int)$customerSession['user']['customer_number'], []);
$subuserResponse = api_client()->get('/departments/categories?id=' . $department['id'], $subuserSession['headers']);
$subuserResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($subuserResponse->data())
->toBeArray()
->toHaveCount(1)
->and($subuserResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
});
it('rejects invalid department category requests', function (): void {
api_test_covers('GET /departments/categories', 'failure');
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
usesApiSuite();
function error_report_api_payload(array $overrides = []): array
{
return array_replace_recursive([
'before_error' => 'Opening the orders page',
'expected' => 'The orders should load',
'actual' => 'The page showed an error',
'data_collection_accepted' => true,
'data_collection_policy_version' => 'error-report-v1',
'route_path' => '/admin/orders',
'page_url' => 'https://app.example.test/admin/orders',
'release_trace_id' => 'trace-error-report-test',
'frontend_version' => 'frontend-test',
'api_version' => 'api-test',
'request_errors' => [
['method' => 'GET', 'url' => '/orders', 'statusCode' => 500],
],
'vue_errors' => [
['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']],
],
'context' => [
'viewport' => ['width' => 1280, 'height' => 720],
'user_agent' => 'ErrorReportsApiTest',
'captured_at' => '2026-07-06T10:00:00.000Z',
'data_collection_policy_version' => 'error-report-v1',
],
], $overrides);
}
function error_report_api_cleanup(array $report): void
{
$id = (int)($report['id'] ?? 0);
if ($id > 0) {
api_fixtures()->cleanupDeleteById('error_reports', $id);
}
}
it('creates error reports when screenshot capture failed', function (): void {
api_test_covers('POST /error-reports', 'happy');
$session = api_fixtures()->createUserSession();
$response = api_client()->post('/error-reports', error_report_api_payload([
'screenshot' => null,
'context' => [
'screenshot_attachment' => ['status' => 'capture_failed'],
],
]), $session['headers']);
$response
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$report = $response->data();
expect($report['screenshot'])->toBeNull();
expect($report['answers']['before_error'])->toBe('Opening the orders page');
expect($report['request_error_count'])->toBe(1);
expect($report['vue_error_count'])->toBe(1);
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
'status' => 'capture_failed',
'attached' => false,
'mime_type' => null,
'size_bytes' => 0,
]);
error_report_api_cleanup($report);
});
it('creates error reports when an optional screenshot payload is invalid', function (): void {
api_test_covers('POST /error-reports', 'invalid optional screenshot');
$session = api_fixtures()->createUserSession();
$response = api_client()->post('/error-reports', error_report_api_payload([
'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'),
]), $session['headers']);
$response
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$report = $response->data();
expect($report['screenshot'])->toBeNull();
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
'status' => 'invalid',
'attached' => false,
'mime_type' => null,
'size_bytes' => 0,
]);
error_report_api_cleanup($report);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
usesApiSuite();
function order_booking_create_payload(array $customer, array $department, array $product, string $reference): array
{
return [
'customer_number' => (int)$customer['customer_number'],
'department' => (int)$department['id'],
'reg_1' => $reference,
'datetime' => '2026-07-07 10:00:00',
'note' => '',
'reference' => $reference,
'po' => '',
'pickup' => false,
'items' => [
[
'id' => (int)$product['id'],
'quantity' => 1,
],
],
];
}
function order_booking_create_department(string $name): array
{
$branding = api_fixtures()->createBranding([
'name' => $name . ' Brand',
'address' => 'API Booking Street 1',
]);
return api_fixtures()->createDepartment([
'name' => $name,
'branding' => (int)$branding['id'],
]);
}
it('lets customers create their own order bookings without booking permissions', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$session = api_fixtures()->createUserSession(['user']);
$department = order_booking_create_department('Own Booking Department');
$product = api_fixtures()->createProduct(['name' => 'Own Booking Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($session['user'], $department, $product, 'OWNBOOK1'),
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
expect($row)->not->toBeNull();
expect((int)($row['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']);
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');
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']);
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], []);
$department = order_booking_create_department('Subuser Booking Department');
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($customer, $department, $product, 'SUBBOOK1'),
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_own_bookings']);
});
it('lets subusers create own customer order bookings with the bookings add node', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer With Add']);
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], ['BOOKINGS_ADD']);
$department = order_booking_create_department('Subuser Booking Add Department');
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Add Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($customer, $department, $product, 'SUBBOOK2'),
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
expect($row)->not->toBeNull();
expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('still requires elevated access for creating another customer order booking', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']);
$product = api_fixtures()->createProduct(['name' => 'Other Booking Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'),
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_bookings']);
});
it('lets department-scoped users create order bookings for another customer', function (): void {
api_test_covers('POST /order-bookings', 'happy');
$customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']);
$department = order_booking_create_department('Department Scoped Booking Department');
$product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']);
$session = api_fixtures()->createUserSession([
'add_bookings',
'department_access_' . $department['id'],
]);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'),
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
@@ -4,6 +4,73 @@ declare(strict_types=1);
usesApiSuite();
function create_order_item_rule_fixture(array $customerAttributes = []): array
{
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']);
foreach ($customerAttributes as $attribute) {
api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute);
}
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'RULE-CHECK',
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
return [
'customer' => $customer,
'department' => $department,
'order' => $order,
'session' => $session,
];
}
function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse
{
return api_client()->post('/order/items', array_merge([
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $overrides), $headers);
}
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
VALUES (?, 0, ?, ?)'
);
$productIdText = (string)$productId;
$statement->bind_param('isi', $userId, $productIdText, $percentage);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('price_overrides', [
'user_id' => $userId,
'is_category' => 0,
'product_or_category_id' => $productIdText,
]);
}
function custom_pricing_only_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('requires notes when adding the extraordinary chemistry product to an order', function (): void {
api_test_covers('POST /order/items', 'validation');
@@ -15,7 +82,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
'reference' => 'NOTE-REQUIRED',
]);
$product = api_fixtures()->createProduct([
'id' => 902701,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
@@ -49,6 +115,146 @@ it('requires notes when adding the extraordinary chemistry product to an order',
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
});
it('uses a product fixed price instead of the best discount when adding an order item', function (): void {
api_test_covers('POST /order/items', 'pricing');
api_test_covers('GET /products', 'pricing');
$customer = api_fixtures()->createUser(['display_name' => 'Fixed Price Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Fixed Price Cashier']);
$category = api_fixtures()->createCategory(['name' => 'Fixed Price Category']);
$product = api_fixtures()->createProduct([
'name' => 'Fixed Price Product',
'price' => 1000,
'category' => $category['id'],
'apply_category_discount' => 1,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 1,
'product_or_category_id' => (string)$category['id'],
'percentage' => 80,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 0,
'product_or_category_id' => (string)$product['id'],
'percentage' => 10,
'fixed_price' => 350,
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'FIXED-PRICE',
]);
$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'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->data()['price'] ?? null)->toBe(350);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['price'] ?? null)->toBe(350);
});
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
api_test_covers('POST /order/items', 'customer_rules');
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'ONLY-TANK',
]);
$washProduct = api_fixtures()->createProduct([
'name' => 'Forvogn',
'price' => 649,
'category' => 4,
]);
$tankCleaningProduct = api_fixtures()->createProduct([
'name' => 'Saebe/kemi, 1-4 spulehoveder',
'price' => 299,
'category' => 5,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $washProduct['id'],
'quantity' => 1,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $tankCleaningProduct['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
});
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
api_test_covers('POST /order/items', 'customer_rules');
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'REGULAR-WASH',
]);
$washProduct = api_fixtures()->createProduct([
'name' => 'Forvogn',
'price' => 649,
'category' => 4,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $washProduct['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
});
it('does not allow clearing notes for order items whose product requires notes', function (): void {
api_test_covers('PUT /order/items', 'validation');
@@ -63,7 +269,7 @@ it('does not allow clearing notes for order items whose product requires notes',
]);
$product = api_fixtures()->createProduct([
'id' => 902702,
'name' => 'API Note Required Product',
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 199,
'requires_note' => 1,
]);
@@ -75,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']);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client()
->put('/order/items', [
@@ -95,7 +301,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
api_test_covers('GET /products', 'happy');
$product = api_fixtures()->createProduct([
'id' => 902703,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
@@ -111,3 +316,202 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
expect($response->data()['requires_note'] ?? null)->toBeTrue();
});
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Drying add-on',
'category' => 4,
'price' => 50,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'notes' => 'Addon customer rule check',
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture();
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary unrestricted truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Unrestricted add-on',
'category' => 4,
'price' => 50,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('blocks related addon order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary related truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Related extra brush',
'price' => 35,
]);
$primaryItem = api_fixtures()->createOrderItem([
'order_id' => $fixture['order']['id'],
'product_id' => $primaryProduct['id'],
'cashier_id' => $cashier['id'],
'price' => 200,
]);
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'related_item_id' => $primaryItem['id'],
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture([$attribute]);
$product = api_fixtures()->createProduct($productAttributes);
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
})->with([
'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]],
'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]],
'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]],
]);
it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['onlyTankCleaning']);
$nonTankProduct = api_fixtures()->createProduct([
'name' => 'Exterior truck wash',
'price' => 180,
]);
$tankProduct = api_fixtures()->createProduct([
'name' => 'Tank cleaning',
'category' => 5,
'price' => 300,
]);
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
api_test_covers('GET /products', 'happy');
api_test_covers('POST /order/items', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Custom Pricing Products',
'custom_pricing_only' => 1,
]);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
$product = api_fixtures()->createProduct([
'name' => 'Custom Pricing Missing Product',
'category' => $category['id'],
'price' => 12345,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
$session = api_fixtures()->createUserSession([
'list_products',
'add_order_items',
'department_access_' . (int)$department['id'],
]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id']
. '&customer_id=' . (int)$customer['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->body)->not->toContain('12345');
expect($productResponse->body)->not->toContain('3333');
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$otherDepartment['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'CUSTOM-ONLY-ORDER',
]);
$orderItem = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$orderItem
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
});
@@ -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,75 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('treats null-like optional product params as omitted for product detail requests', function (): void {
api_test_covers('GET /products', 'optional-params');
$product = api_fixtures()->createProduct([
'name' => 'Null Query Product',
'price' => 400,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->get(
'/products?id=' . (int)$product['id']
. '&department_id=null&customer_id=null&category_id=null&final_price=false',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id']);
expect($response->body)->not->toContain('department_access_0');
});
it('still requires department access when final product pricing uses a real department', function (): void {
api_test_covers('GET /products', 'permissions');
$department = api_fixtures()->createDepartment(['name' => 'Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Department Priced Product',
'price' => 500,
]);
$session = api_fixtures()->createUserSession(['list_products']);
api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
it('rejects invalid department ids without requesting department access zero', function (): void {
api_test_covers('GET /products', 'validation');
$product = api_fixtures()->createProduct([
'name' => 'Invalid Department Product',
'price' => 600,
]);
$session = api_fixtures()->createUserSession(['list_products']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid department_id');
expect($response->body)->not->toContain('department_access_0');
});
@@ -0,0 +1,56 @@
<?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_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']);
});
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('sets preserves and clears product fixed prices through the user discounts endpoint', function (): void {
api_test_covers('POST /superuser/user/discounts', 'pricing');
api_test_covers('GET /superuser/user/discounts', 'pricing');
$customer = api_fixtures()->createUser(['display_name' => 'Endpoint Fixed Price Customer']);
$product = api_fixtures()->createProduct([
'name' => 'Endpoint Fixed Price Product',
'price' => 900,
]);
$session = api_fixtures()->createUserSession([
'set_custom_price',
'get_custom_prices_other',
]);
$findProductRow = function () use ($customer, $product, $session): array {
$response = api_client()->get(
'/superuser/user/discounts?user_id=' . $customer['id'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
foreach ($response->data() as $row) {
if ((int)($row['product_or_category_id'] ?? 0) === (int)$product['id'] && !($row['is_category'] ?? false)) {
return $row;
}
}
throw new RuntimeException('Expected product override row was not returned.');
};
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 20,
'fixed_price' => 350,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(20);
expect((int)$row['fixed_price'])->toBe(350);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(10);
expect((int)$row['fixed_price'])->toBe(350);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
'fixed_price' => null,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(10);
expect($row['fixed_price'])->toBeNull();
});
it('rejects invalid fixed price payloads for user discounts', function (): void {
api_test_covers('POST /superuser/user/discounts', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Invalid Fixed Price Customer']);
$product = api_fixtures()->createProduct(['name' => 'Invalid Fixed Price Product']);
$category = api_fixtures()->createCategory(['name' => 'Invalid Fixed Price Category']);
$session = api_fixtures()->createUserSession(['set_custom_price']);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
'fixed_price' => '12.5',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => (string)$category['id'],
'is_category' => true,
'discount' => 10,
'fixed_price' => 350,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -71,6 +71,7 @@ final class ApiFixtures
$this->deleteRedisKey('`users`_' . $customerNumber . '_economic_customer_name');
$this->deleteRedisKey('users_' . $userId . '_economic_customer');
$this->deleteRedisKey('`users`_' . $userId . '_economic_customer');
$this->deleteRedisKey('users_' . $userId . '_economic_customer_discount_percentage');
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
});
@@ -78,6 +79,7 @@ final class ApiFixtures
$economicName = (string)($attributes['economic_customer_name'] ?? $displayName);
$this->seedCustomerNameCache($customerNumber, $economicName);
$this->seedEconomicCustomerCache($userId, $customerNumber, $economicName, $email);
$this->seedEconomicCustomerDiscountCache($userId, (int)($attributes['economic_customer_discount_percentage'] ?? 0));
return [
'id' => $userId,
@@ -132,6 +134,7 @@ final class ApiFixtures
'branding' => (int)($attributes['branding'] ?? 0),
'visible' => (int)($attributes['visible'] ?? 1),
'archived' => (int)($attributes['archived'] ?? 0),
'custom_pricing_only' => (int)($attributes['custom_pricing_only'] ?? 0),
'latitude' => $attributes['latitude'] ?? 0.0,
'longitude' => $attributes['longitude'] ?? 0.0,
'order_priority' => (int)($attributes['order_priority'] ?? 0),
@@ -660,6 +663,33 @@ final class ApiFixtures
return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []);
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createPriceOverride(array $attributes): array
{
$userId = (int)($attributes['user_id'] ?? 0);
$objectId = (string)($attributes['product_or_category_id'] ?? '');
if ($userId <= 0 || $objectId === '') {
throw new RuntimeException('Price overrides require user_id and product_or_category_id.');
}
$overrideId = $this->insertRowWithExistingColumns('price_overrides', [
'user_id' => $userId,
'is_category' => (int)($attributes['is_category'] ?? 0),
'product_or_category_id' => $objectId,
'percentage' => (int)($attributes['percentage'] ?? 0),
'fixed_price' => $attributes['fixed_price'] ?? null,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('price_overrides', $overrideId));
return array_merge(['id' => $overrideId], $this->fetchRowById('price_overrides', $overrideId) ?? []);
}
public function linkDepartmentCategory(int $departmentId, int $categoryId): int
{
$linkId = $this->insertRow('department_categories', [
@@ -1709,6 +1739,17 @@ final class ApiFixtures
$this->cleanup->add(fn() => $this->deleteWhere($table, $conditions));
}
public function cacheEconomicCustomerDiscountPercentage(int $userId, int $discountPercentage): void
{
if ($this->redis === null) {
throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.');
}
$key = 'users_' . $userId . '_economic_customer_discount_percentage';
$this->redis->set($key, (string)$discountPercentage);
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
}
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
{
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
@@ -1793,6 +1834,11 @@ final class ApiFixtures
$this->setRedisJson('`users`_' . $userId . '_economic_customer', $payload);
}
private function seedEconomicCustomerDiscountCache(int $userId, int $discountPercentage): void
{
$this->setRedisValue('users_' . $userId . '_economic_customer_discount_percentage', (string)$discountPercentage);
}
/**
* @param array<string, mixed> $data
*/
@@ -2164,6 +2210,16 @@ final class ApiFixtures
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
}
private function setRedisValue(string $key, string $value): void
{
if ($this->redis === null) {
throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.');
}
$this->redis->set($key, $value);
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
}
private function deleteRedisKey(string $key): void
{
if ($this->redis === null) {
@@ -21,6 +21,7 @@ final class ApiSchemaBootstrap
$this->ensureDepartmentArchiveSchema();
$this->ensureOrderInvoiceCollectionSchema();
$this->ensurePriceOverrideSchema();
foreach ($this->viewStatements() as $name => $sql) {
$this->execute($name, $sql);
@@ -89,6 +90,7 @@ CREATE TABLE IF NOT EXISTS `departments` (
`branding` INT NULL DEFAULT NULL,
`visible` TINYINT(1) NOT NULL DEFAULT 1,
`archived` TINYINT(1) NOT NULL DEFAULT 0,
`custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0,
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
`longitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
`order_priority` INT NOT NULL DEFAULT 0,
@@ -772,6 +774,7 @@ CREATE TABLE IF NOT EXISTS `price_overrides` (
`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`),
@@ -932,6 +935,13 @@ SQL,
'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)'
);
}
if (!$this->columnExists('departments', 'custom_pricing_only')) {
$this->execute(
'departments.custom_pricing_only',
'ALTER TABLE `departments` ADD COLUMN `custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0 AFTER `archived`'
);
}
}
private function ensureOrderInvoiceCollectionSchema(): void
@@ -972,6 +982,16 @@ SQL,
}
}
private function ensurePriceOverrideSchema(): void
{
if (!$this->columnExists('price_overrides', 'fixed_price')) {
$this->execute(
'price_overrides.fixed_price',
'ALTER TABLE `price_overrides` ADD COLUMN `fixed_price` INT NULL DEFAULT NULL AFTER `percentage`'
);
}
}
private function columnExists(string $table, string $column): bool
{
$table = $this->db->real_escape_string($table);
@@ -62,6 +62,7 @@ it('returns the raw upstream create response and preserves the requested payload
expect($probe->inner->lastPayload['phone'])->toBe(42331123);
expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123');
expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123');
expect(array_key_exists('ean', $probe->inner->lastPayload))->toBeFalse();
});
it('adds supported CVR company fields to the e-conomic customer payload', function (): void {
@@ -97,3 +98,46 @@ it('adds supported CVR company fields to the e-conomic customer payload', functi
expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788');
expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse();
});
it('adds a normalized EAN to the e-conomic customer payload when provided', function (): void {
$stubResponse = (object)[
'customerNumber' => 42331123,
'name' => 'Truckwash ApS',
];
$probe = new EconomicCreateCustomerProbe($stubResponse);
$probe->createCustomer(
42331123,
'Truckwash ApS',
37781258,
'invoice@truckwash.test',
42331123,
null,
null,
'57 90-001234567',
);
expect($probe->inner->lastPayload['ean'])->toBe('5790001234567');
});
it('rejects EAN values longer than e-conomic accepts', function (): void {
$stubResponse = (object)[
'customerNumber' => 42331123,
'name' => 'Truckwash ApS',
];
$probe = new EconomicCreateCustomerProbe($stubResponse);
$call = static fn() => $probe->createCustomer(
42331123,
'Truckwash ApS',
37781258,
'invoice@truckwash.test',
42331123,
null,
null,
'57900012345678',
);
expect($call)->toThrow(InvalidArgumentException::class, 'EAN must be at most 13 digits.');
expect($probe->inner->lastPayload)->toBe([]);
});
@@ -0,0 +1,12 @@
<?php
it('requires the BOOKINGS_ADD subuser node for own order booking creation', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
expect(is_file($routeFile))->toBeTrue();
$code = (string)file_get_contents($routeFile);
$normalized = preg_replace('/\s+/', ' ', $code);
expect($normalized)->toContain("definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD)");
expect($normalized)->toContain("'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'");
});
@@ -176,6 +176,22 @@ it('creates a new e-conomic customer and returns a created result for new rows',
expect($result['has_account'])->toBeFalse();
});
it('rejects EAN values longer than e-conomic accepts before creating customers', function (): void {
$service = new CustomerMassImportServiceProbe();
$call = static fn() => $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'tgp@example.com',
'ean' => '57900012345678',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'EAN must be at most 13 digits.');
expect($service->createCalls)->toBe([]);
expect($service->bootstrapCalls)->toBe([]);
});
it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->localExists = true;
@@ -31,10 +31,15 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
$content = department_daily_reports_openapi_content_or_skip();
expect($content)->toContain('/departments/daily-reports/overview:');
expect($content)->toContain('/departments/daily-reports/product-targets:');
expect($content)->toContain('/superuser/departments/{id}/overview:');
expect($content)->toContain('operationId: getDailyReportOverview');
expect($content)->toContain('operationId: setDailyReportProductTarget');
expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
expect($content)->toContain('DepartmentDailyReportProductTargetRequest:');
expect($content)->toContain('set_department_daily_report_product_targets');
expect($content)->toContain('target_percentage');
expect($content)->toContain('SuperuserDepartmentOverviewResponse:');
expect($content)->toContain('DepartmentDailyReportMetric:');
expect($content)->toContain('DepartmentDailyReportProductTile:');
@@ -123,6 +123,7 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
public object $complaints_repository;
public array $opening_hours = [];
public array $departments = [];
public array $product_targets_by_department = [];
public array $workfeed_departments = [];
public array $workfeed_shifts = [];
public department_outside_hours_statistics_service $outside_hours_service;
@@ -161,6 +162,11 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
{
return $this->outside_hours_service;
}
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
{
return $this->product_targets_by_department[$department_id] ?? [];
}
}
function fake_daily_report_department(int $id, string $name, array $variables = []): object
@@ -250,6 +256,8 @@ it('builds the overview payload from batched repository data with deterministic
expect($overview['products'][0]['slug'])->toBe('spot-free-lastbil');
expect($overview['products'][0]['title'])->toBe('Spot Free (Lastbil)');
expect($overview['products'][0]['value'])->toBe(3);
expect($overview['products'][0]['target_percentage'])->toBeNull();
expect($overview['products'][0]['target_department_id'])->toBeNull();
expect($overview['products'][1]['title'])->toBe('Fælg flex pr. enhed');
expect($overview['products'][1]['value'])->toBe(2);
expect(array_column($overview['products'], 'title'))->toBe([
@@ -262,6 +270,38 @@ it('builds the overview payload from batched repository data with deterministic
]);
});
it('adds product targets to single department overview payloads when requested', function (): void {
$repository = new FakeDailyReportRepository();
$repository->product_overview = [
24 => ['product_id' => 24, 'quantity' => 3, 'out_of' => 14],
25 => ['product_id' => 25, 'quantity' => 2, 'out_of' => 14],
];
$route = new DepartmentDailyReportsOverviewRouteDouble();
$route->repository = $repository;
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
$route->product_targets_by_department = [
7 => [
24 => 75.5,
25 => 0.0,
],
];
$overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[7], '2026-03-23', '2026-03-23', true]);
$products_by_id = [];
foreach ($overview['products'] as $product) {
$products_by_id[$product['product_id']] = $product;
}
expect($products_by_id[24]['target_percentage'])->toBe(75.5);
expect($products_by_id[24]['target_department_id'])->toBe(7);
expect($products_by_id[25]['target_percentage'])->toBe(0.0);
expect($products_by_id[25]['target_department_id'])->toBe(7);
expect($products_by_id[27]['target_percentage'])->toBeNull();
expect($products_by_id[27]['target_department_id'])->toBeNull();
});
it('marks overtime unavailable when not every selected department can be mapped to workfeed', function (): void {
$repository = new FakeDailyReportRepository();
@@ -342,8 +382,10 @@ it('wires the overview route to batched repository methods and overview path', f
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
expect($routeContent)->toContain('/departments/daily-reports/overview');
expect($routeContent)->toContain('/departments/daily-reports/product-targets');
expect($routeContent)->toContain('/superuser/departments/{id}/overview');
expect($routeContent)->toContain('superuser_fetch_department');
expect($routeContent)->toContain('set_department_daily_report_product_targets');
expect($routeContent)->toContain('/departments/daily-reports/complaints');
expect($routeContent)->toContain('outsideHoursStatisticsService');
expect($routeContent)->toContain('dailyReportComplaintsRepository');
@@ -76,4 +76,6 @@ it('defines error report schema, routes, permissions, storage, and OpenAPI docs'
expect($openapi)->toContain('/error-reports:');
expect($openapi)->toContain('ErrorReportSubmissionRequest');
expect($openapi)->toContain('ErrorReportStatusUpdateRequest');
expect($openapi)->not->toContain(" - screenshot\n");
expect($openapi)->toContain('Reports are accepted without an attachment when capture or upload fails.');
});
@@ -0,0 +1,40 @@
<?php
app_require('modules/economic/helpers/economic_customer.php');
use helpers\economic_customer;
function economic_customer_helper_from_payload(object $payload): economic_customer
{
$reflection = new ReflectionClass(economic_customer::class);
/** @var economic_customer $customer */
$customer = $reflection->newInstanceWithoutConstructor();
$property = $reflection->getProperty('customer_data_object');
$property->setAccessible(true);
$property->setValue($customer, $payload);
return $customer;
}
it('exposes optional EAN and public entry number from fetched e-conomic customer data', function (): void {
$customer = economic_customer_helper_from_payload((object)[
'customerNumber' => 42331123,
'ean' => ' 5790001234567 ',
'publicEntryNumber' => ' DK123456789 ',
]);
expect($customer->getEan())->toBe('5790001234567');
expect($customer->getPublicEntryNumber())->toBe('DK123456789');
});
it('returns null for blank optional e-conomic customer recipient identifiers', function (): void {
$customer = economic_customer_helper_from_payload((object)[
'customerNumber' => 42331123,
'ean' => ' ',
'publicEntryNumber' => '',
]);
expect($customer->getEan())->toBeNull();
expect($customer->getPublicEntryNumber())->toBeNull();
});
@@ -0,0 +1,50 @@
<?php
function economic_ean_openapi_content_or_skip(): string
{
$candidates = [];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
foreach (array_values(array_unique($candidates)) as $candidate) {
if (is_file($candidate)) {
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
function economic_ean_openapi_block(string $content, string $start, string $end): string
{
$start_pos = strpos($content, $start);
$end_pos = strpos($content, $end);
expect($start_pos)->not->toBeFalse();
expect($end_pos)->not->toBeFalse();
expect($end_pos)->toBeGreaterThan($start_pos);
return substr($content, (int)$start_pos, (int)$end_pos - (int)$start_pos);
}
it('documents optional EAN on customer creation endpoints', function (): void {
$content = economic_ean_openapi_content_or_skip();
$register_block = economic_ean_openapi_block($content, '/auth/register/cvr:', '/auth/password-reset/request:');
$economic_customer_block = economic_ean_openapi_block($content, '/modules/economic/customer:', '/economic/layouts:');
foreach ([$register_block, $economic_customer_block] as $block) {
expect($block)->toContain('ean:');
expect($block)->toContain('maxLength: 13');
expect($block)->toContain("pattern: '^[0-9]{1,13}$'");
}
});
@@ -0,0 +1,12 @@
<?php
it('wires EAN and public entry number into e-conomic invoice draft recipients', function (): void {
$content = file_get_contents(app_path('modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('$customer->getEan()');
expect($content)->toContain("\$recipient['ean']");
expect($content)->toContain('$customer->getPublicEntryNumber()');
expect($content)->toContain("\$recipient['publicEntryNumber']");
expect($content)->toContain("'recipient' => \$recipient");
});
@@ -0,0 +1,167 @@
<?php
app_require('classes/economic_v2_versioning_service.php');
app_require('classes/economic_v2_distribution_service.php');
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
if (!class_exists('FakeEconomicV2ProductFixedPriceVersioningService')) {
class FakeEconomicV2ProductFixedPriceVersioningService extends economic_v2_versioning_service
{
public function __construct()
{
}
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
{
return null;
}
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
{
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
{
if (!$is_category && (int)$object_id === 42) {
return [
'customer_number' => $customer_number,
'is_category' => 0,
'object_id' => '42',
'discount' => 10,
'fixed_price' => 350,
];
}
if ($is_category) {
return [
'customer_number' => $customer_number,
'is_category' => 1,
'object_id' => (string)$object_id,
'discount' => 80,
'fixed_price' => null,
];
}
return null;
}
public function runBestEffortBackfill(): array
{
return [];
}
}
}
if (!class_exists('TestableEconomicV2ProductFixedPriceDistributionService')) {
class TestableEconomicV2ProductFixedPriceDistributionService extends economic_v2_distribution_service
{
public function __construct()
{
parent::__construct(new FakeEconomicV2ProductFixedPriceVersioningService());
}
public function exposeCalculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float
{
return $this->calculateOrderOriginalPrice($order_items, $customer_number, $department_id, $timestamp);
}
protected function ensureVersionHistoryAvailable(array $areas): void
{
}
protected function fetchOrdersInRange(string $from_ts, string $to_ts): array
{
return [[
'id' => 1001,
'customer_id' => 35131752,
'department_id' => 7,
'created_at' => '2026-01-05 12:00:00',
'include_in_invoice' => 1,
]];
}
protected function fetchOrderItemsByOrderIds(array $order_ids): array
{
return [
1001 => [[
'product_id' => 42,
'quantity' => 2,
'price' => 0,
]],
];
}
protected function getProductDepartmentPrice(int $product_id, int $department_id): float
{
return 1000.0;
}
protected function isOrderEligible(array $order): bool
{
return true;
}
protected function shouldIncludeCustomerNumber(int $customer_number): bool
{
return $customer_number > 0;
}
protected function parseDepartmentMap(array $department_map): array
{
$parsed = [];
foreach ($department_map as $department_id => $amount) {
$parsed['Department ' . $department_id] = round((float)$amount, 5);
}
return $parsed;
}
protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array
{
return [
'id' => $customer_number,
'customer_number' => $customer_number,
'customer_name' => 'Customer ' . $customer_number,
'transactions' => array_values($transaction_map),
'requires_action' => false,
'meta' => [],
];
}
protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array
{
return [
'id' => $order_id,
'date' => $created_at,
'amount' => round((float)($amount ?? 0.0), 5),
'booked' => true,
'department_id' => $department_id,
'excluded' => !($included ?? true),
];
}
}
}
it('uses product fixed prices before discounts in customer price distributions', function (): void {
$service = new TestableEconomicV2ProductFixedPriceDistributionService();
$result = $service->getCustomerPricesDistribution('2026-01-01', '2026-01-31');
expect($result['collective_results']['total_discount_amount'])->toBe(1300.0);
expect($result['collective_results']['department_discount_totals'][7])->toBe(1300.0);
expect($result['customers'][0]['meta']['customer_prices']['discount_total'])->toBe(1300.0);
expect($result['customers'][0]['transactions'][0]['amount'])->toBe(1300.0);
expect($service->exposeCalculateOrderOriginalPrice(
[[
'product_id' => 42,
'quantity' => 2,
'price' => 0,
]],
35131752,
7,
'2026-01-05 12:00:00'
))->toBe(700.0);
});
@@ -579,6 +579,33 @@ it('uses the highest customer-specific discount in expected price breakdowns', f
]);
});
it('uses a product fixed price before customer discounts in expected price breakdowns', function (): void {
$row = [
'customer_number' => 0,
'product_base_price' => 1000,
'department_price' => null,
'product_fixed_price' => 350,
'product_discount_percentage' => 10,
'category_discount_percentage' => 80,
'apply_category_discount' => 1,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
expect($expected)->toBe(350);
expect($breakdown)->toMatchArray([
'product_price' => 1000,
'effective_base_price' => 1000,
'product_fixed_price' => 350,
'product_discount_percentage' => 10,
'category_discount_percentage' => 80,
'economic_customer_discount_percentage' => 0,
'applied_discount_percentage' => 0,
'expected_price' => 350,
]);
});
it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void {
$service = invoice_period_flag_service_instance();
$reflection = new ReflectionClass(invoice_period_flag_service::class);
@@ -618,6 +645,33 @@ it('uses a preloaded e-conomic global discount in expected price breakdowns', fu
]);
});
it('uses the custom-only sentinel without discounts when department price is missing', function (): void {
$row = [
'customer_number' => 35131752,
'user_id' => 411,
'product_base_price' => 100,
'department_price' => null,
'department_custom_pricing_only' => 1,
'product_discount_percentage' => 50,
'category_discount_percentage' => 25,
'apply_category_discount' => 0,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
expect($expected)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
expect($breakdown)->toMatchArray([
'product_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
'department_price' => null,
'effective_base_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
'product_discount_percentage' => 50,
'category_discount_percentage' => 0,
'applied_discount_percentage' => 0,
'expected_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
]);
});
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
$row = [
'customer_number' => 35131752,
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use classes\customer_order_product_policy;
it('recognizes tankcleaning products by category and legacy names', function (): void {
expect(customer_order_product_policy::isTankCleaningProductRow([
'product_category' => 5,
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
'category_name' => 'Other',
]))->toBeTrue()
->and(customer_order_product_policy::isTankCleaningProductRow([
'product_category' => 3,
'product_name' => 'Tank cleaning 4 spulehoveder',
'category_name' => 'Other',
]))->toBeTrue()
->and(customer_order_product_policy::isTankCleaningProductRow([
'product_category' => 3,
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
'category_name' => 'Tankrens',
]))->toBeTrue();
});
it('detects only tankcleaning violations only for attributed customers and non-tank products', function (): void {
$washProduct = [
'product_category' => 4,
'product_name' => 'Forvogn',
'category_name' => 'Udvendig',
];
$tankCleaningProduct = [
'product_category' => 5,
'product_name' => 'Tank cleaning 4 spulehoveder',
'category_name' => 'Tank cleaning',
];
expect(customer_order_product_policy::onlyTankCleaningViolation(true, $washProduct))->toBeTrue()
->and(customer_order_product_policy::onlyTankCleaningViolation(true, $tankCleaningProduct))->toBeFalse()
->and(customer_order_product_policy::onlyTankCleaningViolation(false, $washProduct))->toBeFalse();
});
@@ -18,6 +18,8 @@ it('parses cached economic customer payloads that use snake_case customer_number
'customer_number' => '42331123',
'name' => 'Truckwash ApS',
'email' => 'jb@truckwash.dk',
'ean' => '5790001234567',
'public_entry_number' => 'DK123456789',
'currency' => 'DKK',
'country' => 'DK',
'barred' => true,
@@ -31,6 +33,8 @@ it('parses cached economic customer payloads that use snake_case customer_number
'zip' => null,
'corporateIdentificationNumber' => null,
'email' => 'jb@truckwash.dk',
'ean' => '5790001234567',
'publicEntryNumber' => 'DK123456789',
'mobilePhone' => null,
'currency' => 'DKK',
'country' => 'DK',
@@ -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')");
});
@@ -105,7 +105,30 @@ namespace classes {
};
}
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null): object
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null, $ean = null): object
{
self::$create_calls[] = [
'number' => (int)$number,
@@ -115,6 +138,7 @@ namespace classes {
'phone' => (int)$phone,
'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone,
'company_information' => $companyInformation,
'ean' => $ean === null ? null : (string)$ean,
];
if (self::$mock_create_exception !== null) {
@@ -424,6 +448,16 @@ namespace {
'expected_error' => 'Parameter cvr must be at least 8 characters long',
'expected_status' => 400,
],
[
'name' => 'Invalid EAN length (too long)',
'params' => array_merge($baseParams, ['ean' => '57900012345678']),
'expected_error' => 'EAN must be at most 13 digits.',
'expected_status' => 400,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 0, 'Invalid EAN must not create e-conomic customers.');
assert_true(count(\classes\email::$sent) === 0, 'Invalid EAN must not send welcome emails.');
},
],
[
'name' => 'CVR lookup failure returns validation error without creating customer',
'params' => array_merge($baseParams, ['cvr' => '11111112']),
@@ -563,7 +597,7 @@ namespace {
],
[
'name' => 'Successful registration bootstraps local user before welcome emails',
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
'params' => array_merge($baseParams, ['contactPhone' => 87654320, 'ean' => '57 90-001234567']),
'setup' => static function (): void {
\classes\economic::$mock_create_response = (object)[
'customerNumber' => 12345678,
@@ -579,6 +613,7 @@ namespace {
assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.');
assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.');
assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.');
assert_true(\classes\economic::$create_calls[0]['ean'] === '5790001234567', 'Fresh registration must pass normalized EAN to e-conomic.');
$companyInformation = \classes\economic::$create_calls[0]['company_information'];
assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.');
assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.');