Compare commits

..
Author SHA1 Message Date
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 Bundgaard 9f797bf6b8 Add opening hours table to API test schema 2026-07-06 14:27:39 +02:00
Jeppe Bundgaard d345db927f Add daily report table to API test schema 2026-07-06 14:16:41 +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 eca7a81f9d Add superuser department overview API 2026-07-06 13:59:40 +02:00
Jeppe Bundgaard 62f2c80dda Scope monthly invoice split endpoint 2026-07-06 13:37:31 +02:00
Jeppe BandJeppe Bundgaard 6f3d7e0f7d Add limited backoffice employee contact fields (#294)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:14:06 +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 B a8fba73d99 Add limited backoffice role permission details (#291)
Adds grouped safe permission metadata for limited backoffice role presets.
2026-07-06 12:24:54 +02:00
Jeppe B 669759461d Merge pull request #293 from copenhagentruckwash/codex/economic-collected-invoice-transfer-speed
Optimize collected e-conomic invoice transfers
2026-07-06 11:49:47 +02:00
Jeppe Bundgaard 38814545c4 Optimize collected e-conomic invoice transfers 2026-07-06 11:31:22 +02:00
Jeppe Bundgaard 215c8d0fbb Add limited backoffice role permission details 2026-07-06 10:38:51 +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
64 changed files with 4876 additions and 258 deletions
+101 -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:
@@ -12717,6 +12757,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -13388,7 +13455,6 @@ components:
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
@@ -13404,10 +13470,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 +13585,7 @@ components:
nullable: true
screenshot:
type: object
nullable: true
additionalProperties: true
answers:
type: object
@@ -21525,6 +21593,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
+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");
@@ -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
@@ -240,7 +240,13 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
return $collected_order_invoices->asArray();
$result = $collected_order_invoices->asArray();
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
if ($transfer_metrics !== null) {
$result['economic_transfer_metrics'] = $transfer_metrics;
}
return $result;
}
/**
@@ -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,6 +13,18 @@ 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';
/**
* Permissions required for managed employees to sign in and appear in the employee login picker.
*
* @var array<int, string>
*/
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
'user',
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
];
/**
* @var array<string, array{label:string,description:string,permissions:array<int,string>}>
*/
@@ -112,6 +126,134 @@ class limited_backoffice_service
],
];
/**
* @var array<string, array{group:string,capability:string}>
*/
private const ROLE_PERMISSION_CAPABILITIES = [
'user' => [
'group' => 'account',
'capability' => 'sign_in',
],
'permissions_list_own' => [
'group' => 'account',
'capability' => 'view_own_permissions',
],
'list_orders' => [
'group' => 'orders',
'capability' => 'view_orders',
],
'add_order' => [
'group' => 'orders',
'capability' => 'create_orders',
],
'edit_order' => [
'group' => 'orders',
'capability' => 'edit_orders',
],
'delete_order' => [
'group' => 'orders',
'capability' => 'delete_orders',
],
'list_order_items' => [
'group' => 'orders',
'capability' => 'view_order_items',
],
'add_order_items' => [
'group' => 'orders',
'capability' => 'create_order_items',
],
'edit_order_items' => [
'group' => 'orders',
'capability' => 'update_order_lines',
],
'delete_order_items' => [
'group' => 'orders',
'capability' => 'remove_order_lines',
],
'charge_order' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'list_bookings' => [
'group' => 'bookings',
'capability' => 'view_department_bookings',
],
'list_own_bookings' => [
'group' => 'bookings',
'capability' => 'view_own_bookings',
],
'edit_bookings' => [
'group' => 'bookings',
'capability' => 'update_bookings',
],
'add_booking' => [
'group' => 'bookings',
'capability' => 'create_bookings',
],
'complete_bookings' => [
'group' => 'bookings',
'capability' => 'mark_bookings_complete',
],
'resend_booking_confirmations' => [
'group' => 'bookings',
'capability' => 'send_booking_confirmations',
],
'department_timebookings_entries_get' => [
'group' => 'time_bookings',
'capability' => 'view_time_booking_entries',
],
'department_timebookings_entries_post' => [
'group' => 'time_bookings',
'capability' => 'create_time_booking_entries',
],
'department_timebookings_entries_put' => [
'group' => 'time_bookings',
'capability' => 'edit_time_booking_entries',
],
'statistics_orders_new' => [
'group' => 'reports',
'capability' => 'view_order_statistics',
],
'statistics_bookings_new' => [
'group' => 'reports',
'capability' => 'view_booking_statistics',
],
self::PERMISSION_ACCESS => [
'group' => 'limited_backoffice',
'capability' => 'open_limited_backoffice',
],
self::PERMISSION_MANAGE_PRICES => [
'group' => 'limited_backoffice',
'capability' => 'manage_department_prices',
],
self::PERMISSION_MANAGE_EMPLOYEES => [
'group' => 'limited_backoffice',
'capability' => 'manage_employee_access',
],
];
/**
* @var array<int, string>
*/
private const ROLE_PERMISSION_GROUP_ORDER = [
'account',
'orders',
'bookings',
'time_bookings',
'reports',
'limited_backoffice',
];
/**
* @var array<int, true>
*/
private const PHONE_COUNTRY_CODES = [
45 => true,
46 => true,
47 => true,
358 => true,
];
/**
* @var array<string, bool>
*/
@@ -119,25 +261,64 @@ 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}>
* @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($permissions),
];
}
return $roles;
}
/**
* @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
private function rolePermissionGroups(array $permissions): array
{
$groups = [];
foreach ($permissions as $permission) {
$capability = self::ROLE_PERMISSION_CAPABILITIES[$permission] ?? null;
if ($capability === null) {
throw new \RuntimeException('Missing limited backoffice role capability for permission: ' . $permission);
}
$group = $capability['group'];
$groups[$group] ??= [];
$groups[$group][] = $capability['capability'];
}
$payload = [];
foreach (self::ROLE_PERMISSION_GROUP_ORDER as $group) {
if (!isset($groups[$group])) {
continue;
}
$payload[] = [
'key' => $group,
'capabilities' => array_values(array_unique($groups[$group])),
];
}
return $payload;
}
/**
* @return array<int, int>
*/
@@ -194,7 +375,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
@@ -209,6 +390,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);
}
@@ -224,8 +406,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',
@@ -257,7 +440,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);
}
@@ -274,7 +458,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,
@@ -291,26 +475,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();
@@ -382,7 +566,8 @@ class limited_backoffice_service
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
$displayName = $this->normalizeRequiredString($payload['display_name'] ?? null, 'Display name is required.');
$password = $this->normalizePassword($payload['password'] ?? null, true);
$email = $this->normalizeOptionalString($payload['email'] ?? null);
$email = $this->normalizeEmail($payload['email'] ?? null, true);
$phone = $this->normalizeOptionalPhonePair($payload);
$mysqli = $this->mysqli();
$mysqli->begin_transaction();
@@ -393,13 +578,23 @@ class limited_backoffice_service
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$statement = $mysqli->prepare(
'INSERT INTO `users` (`customer_number`, `display_name`, `email`, `password`, `group_id`)
VALUES (?, ?, ?, ?, ?)'
'INSERT INTO `users`
(`customer_number`, `display_name`, `email`, `password`, `group_id`, `phone_country_code`, `phone`)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare employee insert.');
}
$statement->bind_param('isssi', $customerNumber, $displayName, $email, $passwordHash, $groupId);
$statement->bind_param(
'isssiii',
$customerNumber,
$displayName,
$email,
$passwordHash,
$groupId,
$phone['phone_country_code'],
$phone['phone']
);
$statement->execute();
$employeeId = (int)$mysqli->insert_id;
$statement->close();
@@ -477,11 +672,12 @@ class limited_backoffice_service
? $this->normalizeRequiredString($payload['display_name'], 'Display name is required.')
: null;
$email = array_key_exists('email', $payload)
? $this->normalizeOptionalString($payload['email'])
? $this->normalizeEmail($payload['email'], true)
: null;
$password = array_key_exists('password', $payload)
? $this->normalizePassword($payload['password'], false)
: null;
$phone = $this->normalizeOptionalPhonePair($payload, false);
$active = array_key_exists('active', $payload)
? (bool)$payload['active']
: $this->isEmployeeRowActive($employee);
@@ -498,7 +694,7 @@ class limited_backoffice_service
try {
if ($active) {
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($roleKey, $newDepartmentIds));
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
}
$userUpdates = [];
@@ -511,6 +707,10 @@ class limited_backoffice_service
if ($password !== null) {
$userUpdates['password'] = password_hash($password, PASSWORD_DEFAULT);
}
if ($phone !== null) {
$userUpdates['phone_country_code'] = $phone['phone_country_code'];
$userUpdates['phone'] = $phone['phone'];
}
$usersHaveDeletedAt = $this->tableHasColumn('users', 'deleted_at');
if ($active) {
$userUpdates['group_id'] = $managedGroupId;
@@ -567,6 +767,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;
@@ -611,13 +852,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);
@@ -636,13 +877,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;
@@ -668,8 +910,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'
);
@@ -685,9 +933,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])) {
@@ -703,10 +957,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'],
@@ -725,7 +981,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),
];
}
@@ -909,6 +1165,89 @@ class limited_backoffice_service
return $value === '' ? null : $value;
}
private function normalizeEmail(mixed $value, bool $required): ?string
{
$email = $this->normalizeOptionalString($value);
if ($email === null) {
if ($required) {
throw new limited_backoffice_exception('Email is required.', 400);
}
return null;
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new limited_backoffice_exception('Email must be a valid email address.', 400);
}
return $email;
}
/**
* @param array<string, mixed> $payload
* @return array{phone_country_code:int|null,phone:int|null}|null
*/
private function normalizeOptionalPhonePair(array $payload, bool $defaultWhenMissing = true): ?array
{
$hasCountryCode = array_key_exists('phone_country_code', $payload);
$hasPhone = array_key_exists('phone', $payload);
if (!$hasCountryCode && !$hasPhone) {
return $defaultWhenMissing
? ['phone_country_code' => null, 'phone' => null]
: null;
}
if (!$hasCountryCode || !$hasPhone) {
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
}
$countryCode = $this->normalizeOptionalDigits($payload['phone_country_code']);
$phone = $this->normalizeOptionalDigits($payload['phone']);
if ($countryCode === null && $phone === null) {
return ['phone_country_code' => null, 'phone' => null];
}
if ($countryCode === null || $phone === null) {
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
}
if (!isset(self::PHONE_COUNTRY_CODES[$countryCode])) {
throw new limited_backoffice_exception('Phone country code is not supported.', 400);
}
$phoneText = (string)$phone;
if (!preg_match('/^\d{4,15}$/', $phoneText)) {
throw new limited_backoffice_exception('Phone number must be 4-15 digits.', 400);
}
return [
'phone_country_code' => $countryCode,
'phone' => $phone,
];
}
private function normalizeOptionalDigits(mixed $value): ?int
{
if ($value === null) {
return null;
}
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_string($value)) {
$value = trim($value);
if ($value === '') {
return null;
}
if (ctype_digit($value)) {
return (int)$value;
}
}
throw new limited_backoffice_exception('Phone values must contain digits only.', 400);
}
private function normalizePassword(mixed $value, bool $required): ?string
{
if ($value === null || $value === '') {
@@ -951,18 +1290,39 @@ 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;
}
/**
* @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 ($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;
}
@@ -1081,9 +1441,12 @@ 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'],
'phone_country_code' => $row['phone_country_code'] === null ? null : (int)$row['phone_country_code'],
'phone' => $row['phone'] === null ? null : (int)$row['phone'],
'active' => $active,
'role' => $this->rolePayload((string)$row['role_key']),
'departments' => $this->departmentSummaries($departmentIds),
@@ -1204,7 +1567,7 @@ class limited_backoffice_service
$types = '';
$values = [];
foreach ($fields as $field => $value) {
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at'], true)) {
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at', 'phone_country_code', 'phone'], true)) {
continue;
}
if ($value === null) {
@@ -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));
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
* @throws Exception If the request fails
*/
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
{
$this->add_orders($invoiceDraftId, [$order], $currency);
}
/**
* Add many orders to a draft invoice and flush their lines in batches.
*
* @param orders_o[] $orders
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
{
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() > 0) {
$orders_with_invoice_lines = 0;
foreach ( $orders as $order ) {
if (!$order instanceof orders_o) {
throw new Exception('Order payload must contain orders_o instances');
}
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() <= 0) {
continue;
}
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order);
// Add the order lines
$draftInvoice->addOrderItemLines($order);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
// Save the draft invoice lines
$draftInvoice->addLines();
}
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
return [
'order_count' => count($orders),
'orders_with_invoice_lines' => $orders_with_invoice_lines,
...$metrics,
];
}
/**
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
$draft_invoice->addLines();
}
}
}
}
@@ -10,6 +10,8 @@ use objects\orders_o;
class economic_invoice_draft
{
public const DEFAULT_LINE_BATCH_SIZE = 500;
/**
* The Economic draftInvoiceNumber
* @var int $draft_invoice_number
@@ -110,13 +112,55 @@ class economic_invoice_draft
}
/**
* Add the lines to the draft invoice
* @return void
* Add the lines to the draft invoice.
*/
public function addLines(): void
{
$this->flushLinesInBatches();
}
/**
* Add queued draft lines using chunked requests.
*
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
*/
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
{
$lines = array_values($this->draft_lines);
$line_count = count($lines);
if ($line_count === 0) {
return [
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
];
}
$batch_size = max(1, $batch_size);
$batch_sizes = [];
foreach (array_chunk($lines, $batch_size) as $batch) {
$this->sendDraftLines($batch);
$batch_sizes[] = count($batch);
}
$this->draft_lines = [];
return [
'line_count' => $line_count,
'batch_count' => count($batch_sizes),
'batch_sizes' => $batch_sizes,
];
}
public function pendingLineCount(): int
{
return count($this->draft_lines);
}
protected function sendDraftLines(array $draft_lines): object
{
$economic = new economic();
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
}
/**
@@ -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
@@ -37,6 +37,7 @@ class collected_order_invoices_o extends db
public object_property $updated_at;
public object_property $closed_at;
public int $economic_wash_subscription_user_id = 1857;
private ?array $last_economic_transfer_metrics = null;
/**
* The processor types
*
@@ -712,6 +713,7 @@ class collected_order_invoices_o extends db
*/
public function addInvoicesToDraft(bool $skip_check = false): self
{
$this->last_economic_transfer_metrics = null;
// Require the invoice collection to be selected
self::requireSelected();
// Require the invoice collection to be open
@@ -736,10 +738,20 @@ class collected_order_invoices_o extends db
usort($orders, function ($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
// Add the invoices to the invoice draft
// Add the invoice lines to the draft in one accumulated batch path.
$order_objects = [];
foreach ( $orders as $order ) {
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
$order_object = new orders_o();
$order_object->select((int)$order['id']);
$order_object->requireSelected();
$order_objects[] = $order_object;
}
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
...$metrics,
];
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
// Object changed
@@ -748,6 +760,11 @@ class collected_order_invoices_o extends db
return $this;
}
public function getLastEconomicTransferMetrics(): ?array
{
return $this->last_economic_transfer_metrics;
}
/**
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
* @param int $draft_id The invoice draft id
@@ -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')
+117 -5
View File
@@ -3098,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
@@ -3114,7 +3114,7 @@ paths:
post:
tags:
- Users
summary: Set user discount
summary: Set user discount or product fixed price
operationId: setUserDiscount
requestBody:
required: true
@@ -3128,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
@@ -12535,6 +12540,40 @@ paths:
application/json:
schema: {}
/superuser/departments/{id}/overview:
get:
tags:
- Departments
summary: Get superuser department overview
description: Returns the selected department metadata and operational overview metrics for a superuser without requiring scoped department access.
operationId: getSuperuserDepartmentOverview
parameters:
- name: id
in: path
required: true
schema: {type: integer}
- name: date
in: query
required: true
schema: {type: string}
- name: date_to
in: query
required: false
schema: {type: string}
responses:
'200':
description: Superuser department overview loaded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuperuserDepartmentOverviewResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/department/branding:
put:
tags:
@@ -12729,6 +12768,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -13400,7 +13466,6 @@ components:
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
@@ -13416,10 +13481,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
@@ -13530,6 +13596,7 @@ components:
nullable: true
screenshot:
type: object
nullable: true
additionalProperties: true
answers:
type: object
@@ -21537,6 +21604,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
@@ -21562,6 +21659,21 @@ components:
data:
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
SuperuserDepartmentOverviewPayload:
type: object
properties:
department:
$ref: '#/components/schemas/Department'
overview:
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
SuperuserDepartmentOverviewResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/SuperuserDepartmentOverviewPayload'
DepartmentDailyReportTransactionCountPayload:
type: object
properties:
@@ -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);
}
@@ -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 () {
@@ -812,6 +815,54 @@ class departmentDailyReportsRoute
]
);
$this->get('/superuser/departments/{id}/overview', function () {
global $response;
$this->requirePermission('superuser_fetch_department');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$department_id_param = (string)($this->fromRoute('id') ?? '');
if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) {
$response->error('Parameter id must be a positive integer', 400);
return;
}
self::requireParameters([
'date',
]);
self::validateDateLocally();
$date_to = $this->getDate_to();
$department_id = (int)$department_id_param;
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview');
$response->success([
'department' => $department->asArray(['slack_webhook' => false]),
'overview' => $this->buildDailyReportOverview(
[$department_id],
(string)self::getParameter('date'),
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
),
]);
},
[
'superuser_fetch_department' => 'Get the superuser department overview'
]
);
$this->get('/departments/daily-reports/overview', function () {
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -848,7 +899,8 @@ class departmentDailyReportsRoute
$this->buildDailyReportOverview(
$department_ids,
(string)self::getParameter('date'),
$date_to
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
)
);
},
@@ -859,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;
@@ -1322,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);
@@ -1342,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,
@@ -1352,7 +1476,8 @@ class departmentDailyReportsRoute
$product_summary_lookup,
$complaints_metric,
$night_wash_metric,
$overtime_metric
$overtime_metric,
$product_target_lookup
);
}
@@ -1365,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,
@@ -1383,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) {
@@ -1401,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,
];
}
@@ -1459,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,17 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage 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);
@@ -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 = [];
+41 -5
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,6 +71,10 @@ 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);
}
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
@@ -77,6 +82,19 @@ class orderItemsRoute
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
$customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) {
(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);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o());
@@ -187,7 +205,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,16 +230,34 @@ 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);
}
+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
+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(
+78 -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,44 @@ 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 (!$enabled || $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) {
return [
'enabled' => false,
'filters' => $filters,
'additional_where' => null,
];
}
unset($filterArray['customer_number']);
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
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);
});
@@ -20,6 +20,34 @@ function limited_backoffice_manager_session(array $departmentIds, array $extraPe
return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions))));
}
function limited_backoffice_all_role_permissions(): array
{
return [
'user',
'permissions_list_own',
'list_orders',
'add_order',
'edit_order',
'delete_order',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'charge_order',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'complete_bookings',
'resend_booking_confirmations',
'department_timebookings_entries_get',
'department_timebookings_entries_post',
'department_timebookings_entries_put',
'statistics_orders_new',
'statistics_bookings_new',
];
}
function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
@@ -47,6 +75,14 @@ function limited_backoffice_price_value(int $departmentId, int $productId): ?int
return $row === null ? null : (int)$row['price'];
}
function limited_backoffice_price_rows(int $departmentId, int $productId): array
{
return api_test_runtime()->db()->query(
'SELECT `id`, `price` FROM `product_department_prices` WHERE `department_id` = ' . $departmentId .
' AND `product_id` = ' . $productId . ' ORDER BY `id` ASC'
)->fetch_all(MYSQLI_ASSOC);
}
function limited_backoffice_cleanup_created_employee(int $employeeId): void
{
$row = api_test_runtime()->queryOne(
@@ -172,6 +208,63 @@ it('lists and updates explicit prices only for assigned departments', function (
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(2222);
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$product['id']))->toBe(4321);
expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(2222);
});
it('returns saved prices and collapses legacy duplicate department price rows', function (): void {
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'legacy duplicates');
$department = api_fixtures()->createDepartment(['name' => 'Limited Legacy Duplicate Prices']);
$category = api_fixtures()->createCategory(['name' => 'Limited Legacy Duplicate Category']);
$product = api_fixtures()->createProduct([
'name' => 'Legacy Duplicate Price',
'category' => $category['id'],
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
$db = api_test_runtime()->db();
$index = $db->query("SHOW INDEX FROM `product_department_prices` WHERE `Key_name` = 'uniq_product_department_prices_lookup'");
if ($index === false) {
throw new RuntimeException('Unable to inspect product_department_prices lookup index.');
}
$hadIndex = (int)$index->num_rows > 0;
if ($hadIndex) {
$db->query('ALTER TABLE `product_department_prices` DROP INDEX `uniq_product_department_prices_lookup`');
}
try {
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 111);
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 222);
expect(limited_backoffice_price_rows((int)$department['id'], (int)$product['id']))->toHaveCount(2);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$product['id'], 'price' => 333],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = limited_backoffice_price_rows((int)$department['id'], (int)$product['id']);
expect($rows)->toHaveCount(1);
expect((int)$rows[0]['price'])->toBe(333);
expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(333);
} finally {
$db->query(
'DELETE FROM `product_department_prices` WHERE `department_id` = ' . (int)$department['id'] .
' AND `product_id` = ' . (int)$product['id']
);
if ($hadIndex) {
$db->query(
'ALTER TABLE `product_department_prices`
ADD UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`)'
);
}
}
});
it('updates department prices when the price table has no updated_at column', function (): void {
@@ -211,6 +304,68 @@ it('updates department prices when the price table has no updated_at column', fu
});
});
it('deduplicates products from duplicate department category links', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe');
$department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Products']);
$category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Category']);
$firstProduct = api_fixtures()->createProduct([
'name' => 'Duplicate Price A',
'category' => $category['id'],
]);
$secondProduct = api_fixtures()->createProduct([
'name' => 'Duplicate Price B',
'category' => $category['id'],
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
limited_backoffice_price_insert((int)$department['id'], (int)$firstProduct['id'], 111);
limited_backoffice_price_insert((int)$department['id'], (int)$secondProduct['id'], 222);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$productIds = [];
foreach ($response->data()['categories'] as $departmentCategory) {
foreach ($departmentCategory['products'] as $departmentProduct) {
$productIds[] = (int)$departmentProduct['id'];
}
}
expect($productIds)->toBe([(int)$firstProduct['id'], (int)$secondProduct['id']]);
});
it('deduplicates missing product setup gaps from duplicate department category links', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe failure');
$department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Setup Gap']);
$category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Setup Category']);
$product = api_fixtures()->createProduct([
'name' => 'Duplicate Missing Product',
'category' => $category['id'],
'price' => 88888,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Department price setup is incomplete.');
expect(array_column($response->data()['missing_products'], 'id'))->toBe([(int)$product['id']]);
expect($response->body)->not->toContain('88888');
});
it('rejects cross-department price access, body spoofing, and outside products', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'auth');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'auth');
@@ -285,6 +440,85 @@ it('fails price setup gaps without exposing product defaults', function (): void
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
});
it('defaults missing custom-only department prices to sentinel without exposing fallback prices', function (): void {
api_test_covers('GET /limited-backoffice/departments', 'happy');
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Limited Custom Pricing Only',
'custom_pricing_only' => 1,
]);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Other Pricing']);
$category = api_fixtures()->createCategory(['name' => 'Limited Custom Pricing Category']);
$product = api_fixtures()->createProduct([
'name' => 'Custom Missing Product',
'category' => $category['id'],
'price' => 87654,
]);
$otherProduct = api_fixtures()->createProduct([
'name' => 'Custom Missing Other Product',
'category' => $category['id'],
'price' => 76543,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321);
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$otherProduct['id'], 5432);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$departments = api_client()->get('/limited-backoffice/departments', $session['headers']);
$departments
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($departments->data()[0]['custom_pricing_only'] ?? null)->toBeTrue();
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->body)->not->toContain('87654');
expect($response->body)->not->toContain('76543');
expect($response->body)->not->toContain('4321');
expect($response->body)->not->toContain('5432');
expect($response->data()['department']['custom_pricing_only'] ?? null)->toBeTrue();
$products = [];
foreach ($response->data()['categories'] as $departmentCategory) {
foreach ($departmentCategory['products'] as $departmentProduct) {
$products[(int)$departmentProduct['id']] = $departmentProduct;
}
}
expect($products[(int)$product['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
expect($products[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$product['id'], 'price' => 2222],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$updatedProducts = [];
foreach ($updated->data()['categories'] as $departmentCategory) {
foreach ($departmentCategory['products'] as $departmentProduct) {
$updatedProducts[(int)$departmentProduct['id']] = $departmentProduct;
}
}
expect($updated->body)->not->toContain('87654');
expect($updated->body)->not->toContain('76543');
expect($updated->body)->not->toContain('4321');
expect($updated->body)->not->toContain('5432');
expect($updatedProducts[(int)$product['id']]['price'] ?? null)->toBe(2222);
expect($updatedProducts[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
});
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
@@ -313,6 +547,21 @@ it('rejects invalid price batches and leaves existing prices unchanged', functio
expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200);
}
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$firstProduct['id'], 'price' => 999],
['product_id' => (int)$firstProduct['id'], 'price' => 888],
['product_id' => (int)$secondProduct['id'], 'price' => 777],
],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Duplicate product price rows are not allowed.');
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200);
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$firstProduct['id'], 'price' => 999],
@@ -339,7 +588,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect((int)$usersDeletedAtColumn->num_rows)->toBe(0);
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$session = limited_backoffice_manager_session([(int)$department['id']], limited_backoffice_all_role_permissions());
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
$roles
@@ -348,11 +597,60 @@ it('creates updates lists and deactivates scoped employees without exposing raw
->assertSuccess();
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
$rolesByKey = array_column($roles->data(), null, 'key');
expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([
[
'key' => 'account',
'capabilities' => ['sign_in', 'view_own_permissions'],
],
]);
$departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key');
expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([
'open_limited_backoffice',
'manage_department_prices',
'manage_employee_access',
]);
expect($roles->body)->not->toContain('department_access_');
$rolePayload = $roles->data();
$rolePayloadStrings = [];
array_walk_recursive($rolePayload, static function ($value) use (&$rolePayloadStrings): void {
if (is_string($value)) {
$rolePayloadStrings[] = $value;
}
});
foreach ([
'list_orders',
'add_order',
'edit_order',
'delete_order',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'charge_order',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'complete_bookings',
'resend_booking_confirmations',
'department_timebookings_entries_get',
'department_timebookings_entries_post',
'department_timebookings_entries_put',
'statistics_orders_new',
'statistics_bookings_new',
'limited_backoffice_access',
'limited_backoffice_prices_manage',
'limited_backoffice_employees_manage',
] as $rawPermission) {
expect($rolePayloadStrings)->not->toContain($rawPermission);
}
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Cashier',
'email' => 'limited-cashier@example.test',
'phone_country_code' => 45,
'phone' => 12345678,
'password' => 'Secret123!',
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
@@ -366,6 +664,10 @@ it('creates updates lists and deactivates scoped employees without exposing raw
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
expect($created->data()['user_id'] ?? null)->toBe($employeeId);
expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test');
expect($created->data()['phone_country_code'] ?? null)->toBe(45);
expect($created->data()['phone'] ?? null)->toBe(12345678);
expect($created->body)->not->toContain('department_access_');
expect($created->body)->not->toContain('permissions');
@@ -379,11 +681,19 @@ it('creates updates lists and deactivates scoped employees without exposing raw
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('department_access_' . (int)$department['id'])
->toContain('employee_public_data')
->toContain('add_order')
->not->toContain('superuser');
$publicEmployees = api_client()->get('/public/employees');
$publicEmployeeIds = array_map('intval', array_column($publicEmployees->data(), 'id'));
expect($publicEmployeeIds)->toContain($employeeId);
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
'display_name' => 'Limited Lead',
'email' => 'limited-lead@example.test',
'phone_country_code' => 358,
'phone' => 87654321,
'role_key' => 'operations_lead',
'department_ids' => [(int)$department['id']],
], $session['headers']);
@@ -393,11 +703,26 @@ it('creates updates lists and deactivates scoped employees without exposing raw
->assertSuccess();
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
expect($updated->data()['email'] ?? null)->toBe('limited-lead@example.test');
expect($updated->data()['phone_country_code'] ?? null)->toBe(358);
expect($updated->data()['phone'] ?? null)->toBe(87654321);
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
$ids = array_column($list->data(), 'id');
expect($ids)->toContain($employeeId);
$listedEmployee = null;
foreach ($list->data() as $employee) {
if ((int)($employee['id'] ?? 0) === $employeeId) {
$listedEmployee = $employee;
break;
}
}
expect($listedEmployee)->not->toBeNull();
expect($listedEmployee['user_id'] ?? null)->toBe($employeeId);
expect($listedEmployee['email'] ?? null)->toBe('limited-lead@example.test');
expect($listedEmployee['phone_country_code'] ?? null)->toBe(358);
expect($listedEmployee['phone'] ?? null)->toBe(87654321);
expect($list->body)->not->toContain('department_access_');
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
@@ -412,6 +737,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect(array_key_exists('password', $userRow ?? []))->toBeTrue();
expect($userRow['password'])->toBeNull();
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
$publicEmployeesAfterDeactivation = api_client()->get('/public/employees');
$publicEmployeeIdsAfterDeactivation = array_map('intval', array_column($publicEmployeesAfterDeactivation->data(), 'id'));
expect($publicEmployeeIdsAfterDeactivation)->not->toContain($employeeId);
$employeeRow = api_test_runtime()->queryOne(
'SELECT `deactivated_at` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
);
@@ -419,6 +747,355 @@ it('creates updates lists and deactivates scoped employees without exposing raw
});
});
it('caps limited employee permissions to the manager permissions and selected departments', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'auth');
api_test_covers('GET /limited-backoffice/roles', 'auth');
$department = api_fixtures()->createDepartment(['name' => 'Limited Permission Cap']);
$session = limited_backoffice_manager_session([(int)$department['id']], [
'list_orders',
]);
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
$rolesByKey = array_column($roles->data(), null, 'key');
$operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key');
expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in']);
expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']);
expect($roles->body)->not->toContain('create_orders');
expect($roles->body)->not->toContain('view_order_statistics');
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Capped Lead',
'email' => 'limited-capped@example.test',
'password' => 'Secret123!',
'role_key' => 'operations_lead',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$groupRow = api_test_runtime()->queryOne(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
);
$groupId = (int)($groupRow['managed_group_id'] ?? 0);
$permissionRows = api_test_runtime()->db()->query(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $groupId
)->fetch_all(MYSQLI_ASSOC);
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('user')
->toContain('employee_public_data')
->toContain('list_orders')
->toContain('department_access_' . (int)$department['id'])
->not->toContain('add_order')
->not->toContain('delete_order')
->not->toContain('statistics_orders_new')
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
});
it('generates reusable QR login links for active scoped employees', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Department']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited QR Employee',
'email' => 'limited-qr@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$response = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-link',
[],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$loginPath = (string)($response->data()['login_path'] ?? '');
expect($response->data()['employee_id'] ?? null)->toBe($employeeId);
expect($loginPath)->toMatch('/^\/login\/qr\?token=[a-f0-9]{64}$/');
parse_str((string)parse_url($loginPath, PHP_URL_QUERY), $query);
$token = (string)($query['token'] ?? '');
expect($token)->toMatch('/^[a-f0-9]{64}$/');
$tokenRow = api_test_runtime()->queryOne(
"SELECT `user_id`, `type` FROM `tokens` WHERE `token` = '" .
api_test_runtime()->db()->real_escape_string($token) .
"' LIMIT 1"
);
expect($tokenRow)->not->toBeNull();
expect((int)($tokenRow['user_id'] ?? 0))->toBe($employeeId);
expect($tokenRow['type'] ?? null)->toBe('AUTH_TOKEN');
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
$list
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($list->body)->not->toContain($token);
expect($list->body)->not->toContain('login_path');
});
it('rejects invalid limited backoffice employee QR login link generation', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth');
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation');
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Own']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Login Link Other']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$otherSession = limited_backoffice_manager_session([(int)$otherDepartment['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Link Target',
'email' => 'limited-link-target@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$withoutManageEmployees = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
'department_access_' . (int)$department['id'],
]);
api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-link',
[],
$withoutManageEmployees['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES]);
api_client()->post(
'/limited-backoffice/employees/' . (int)$session['user']['id'] . '/login-link',
[],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managers cannot edit themselves.');
api_client()->post('/limited-backoffice/employees/999999999/login-link', [], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managed employee not found.');
$otherCreated = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Other Department Target',
'email' => 'limited-other-target@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$otherDepartment['id']],
], $otherSession['headers']);
$otherEmployeeId = (int)($otherCreated->data()['id'] ?? 0);
expect($otherEmployeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($otherEmployeeId);
api_client()->post(
'/limited-backoffice/employees/' . $otherEmployeeId . '/login-link',
[],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
api_client()->post('/limited-backoffice/employees/' . $employeeId . '/login-link', [], $session['headers'])
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot create a login link for an inactive employee.');
$superuser = api_fixtures()->createUser(['group_id' => 1]);
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$superuser['id'] . ", 1, 'department_admin', '[" . (int)$department['id'] . "]', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$superuser['id']]);
api_client()->post('/limited-backoffice/employees/' . (int)$superuser['id'] . '/login-link', [], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage superuser accounts.');
$sharedGroup = api_fixtures()->createGroup();
$firstSharedUser = api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
$departmentJson = '[' . (int)$department['id'] . ']';
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$firstSharedUser['id'] . ', ' . (int)$sharedGroup['id'] . ", 'viewer', '" . $departmentJson . "', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$firstSharedUser['id']]);
api_client()->post('/limited-backoffice/employees/' . (int)$firstSharedUser['id'] . '/login-link', [], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage shared groups.');
});
it('includes limited employees in the regular employee list and protects raw user edits', function (): void {
api_test_covers('GET /users', 'limited backoffice employee list');
api_test_covers('PUT /users', 'limited backoffice guard');
$department = api_fixtures()->createDepartment(['name' => 'Limited Regular List']);
$managerSession = limited_backoffice_manager_session([(int)$department['id']], [
'permissions_list_own',
]);
$regularEmployee = api_fixtures()->createUser([
'customer_number' => 0,
'display_name' => 'Regular Backoffice Employee',
], ['employee_public_data']);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Listed Employee',
'email' => 'limited-listed@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $managerSession['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$adminSession = api_fixtures()->createUserSession([
'list_users',
'edit_user',
]);
$withoutLimited = api_client()->get('/users?page=1&limit=50&filters=customer_number:0', $adminSession['headers']);
$withoutLimitedIds = array_map('intval', array_column($withoutLimited->data(), 'id'));
expect($withoutLimitedIds)->toContain((int)$regularEmployee['id']);
expect($withoutLimitedIds)->not->toContain($employeeId);
$withLimited = api_client()->get(
'/users?page=1&limit=50&filters=customer_number:0&include_limited_backoffice_employees=true',
$adminSession['headers']
);
$usersById = array_column($withLimited->data(), null, 'id');
expect(array_keys($usersById))->toContain((int)$regularEmployee['id']);
expect(array_keys($usersById))->toContain($employeeId);
expect($usersById[$employeeId]['limited_backoffice_managed'] ?? null)->toBeTrue();
expect($usersById[(int)$regularEmployee['id']]['limited_backoffice_managed'] ?? null)->toBeFalse();
$userRow = api_test_runtime()->queryOne(
'SELECT `customer_number`, `group_id` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1'
);
$customerNumber = (int)($userRow['customer_number'] ?? 0);
$groupId = (int)($userRow['group_id'] ?? 0);
api_client()->put('/users', [
'id' => $employeeId,
'customer_number' => $customerNumber + 1,
'role' => $groupId,
'display_name' => 'Blocked Customer Change',
], $adminSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Limited backoffice managed users cannot change customer number.');
api_client()->put('/users', [
'id' => $employeeId,
'customer_number' => $customerNumber,
'role' => 0,
'display_name' => 'Blocked Role Change',
], $adminSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Limited backoffice managed users cannot change role.');
api_client()->put('/users', [
'id' => $employeeId,
'customer_number' => $customerNumber,
'role' => $groupId,
'display_name' => 'Edited Limited Listed Employee',
], $adminSession['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$updatedUserRow = api_test_runtime()->queryOne(
'SELECT `customer_number`, `group_id`, `display_name` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1'
);
expect((int)($updatedUserRow['customer_number'] ?? 0))->toBe($customerNumber);
expect((int)($updatedUserRow['group_id'] ?? 0))->toBe($groupId);
expect($updatedUserRow['display_name'] ?? null)->toBe('Edited Limited Listed Employee');
});
it('accepts employees without optional phone details', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee No Phone']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited No Phone',
'email' => 'limited-no-phone@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue();
expect(array_key_exists('phone', $created->data()))->toBeTrue();
expect($created->data()['phone_country_code'])->toBeNull();
expect($created->data()['phone'])->toBeNull();
});
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'validation');
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
@@ -429,6 +1106,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Outside Employee',
'email' => 'outside@example.test',
'password' => 'Secret123!',
'role_key' => 'cashier',
'department_ids' => [(int)$otherDepartment['id']],
@@ -440,6 +1118,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Raw Employee',
'email' => 'raw@example.test',
'password' => 'Secret123!',
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
@@ -452,6 +1131,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Unknown Role Employee',
'email' => 'unknown-role@example.test',
'password' => 'Secret123!',
'role_key' => 'superuser',
'department_ids' => [(int)$department['id']],
@@ -504,3 +1184,88 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
->assertSuccess(false)
->assertMessage('Cannot manage shared groups.');
});
it('rejects invalid limited backoffice employee contact details', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'validation');
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Contact Validation']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$basePayload = [
'display_name' => 'Contact Employee',
'email' => 'contact@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
];
api_client()->post('/limited-backoffice/employees', array_diff_key($basePayload, ['email' => true]), $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Email is required.');
api_client()->post('/limited-backoffice/employees', [
...$basePayload,
'email' => 'not-an-email',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Email must be a valid email address.');
api_client()->post('/limited-backoffice/employees', [
...$basePayload,
'phone_country_code' => 45,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Phone country code and phone number must be provided together.');
api_client()->post('/limited-backoffice/employees', [
...$basePayload,
'phone_country_code' => 1,
'phone' => 12345678,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Phone country code is not supported.');
api_client()->post('/limited-backoffice/employees', [
...$basePayload,
'phone_country_code' => 45,
'phone' => '12ab',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Phone values must contain digits only.');
$created = api_client()->post('/limited-backoffice/employees', $basePayload, $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
'email' => '',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Email is required.');
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
'phone_country_code' => 45,
'phone' => '123',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Phone number must be 4-15 digits.');
});
@@ -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']);
$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']);
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);
});
@@ -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,100 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('loads a single department overview for superusers without department scoped access', function (): void {
api_test_covers('GET /superuser/departments/{id}/overview', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Overview Department ' . uniqid('', false),
'description' => 'Department overview fixture',
'economic_department_id' => 42,
'visible' => 1,
]);
$departmentRow = api_fixtures()->fetchRowById('departments', (int)$department['id']);
$session = api_fixtures()->createUserSession([
'superuser_fetch_department',
]);
$response = api_client()->get(
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06&date_to=2026-07-06',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
expect($payload)->toBeArray();
expect($payload['department'])
->toBeArray()
->toHaveKey('id', (int)$department['id'])
->toHaveKey('name', $departmentRow['name'])
->toHaveKey('description', 'Department overview fixture')
->toHaveKey('economic_department_id', 42);
expect($payload['overview'])
->toBeArray()
->toHaveKey('department_ids', [(int)$department['id']])
->toHaveKey('date', '2026-07-06')
->toHaveKey('date_to', '2026-07-06');
expect($payload['overview']['metrics'])
->toBeArray()
->toHaveKeys([
'bookings',
'complaints',
'night_washes',
'revenue',
'washes',
'products_sold',
'transactions',
'water_usage',
'overtime',
]);
expect($payload['overview']['metrics']['revenue']['state'])->toBe('ready');
expect($payload['overview']['metrics']['revenue']['value'])->toBe(0);
expect($payload['overview']['products'])->toBeArray();
});
it('rejects superuser department overview requests without permission or valid input', function (): void {
api_test_covers('GET /superuser/departments/{id}/overview', 'auth');
api_test_covers('GET /superuser/departments/{id}/overview', 'failure');
$department = api_fixtures()->createDepartment();
$unauthorizedSession = api_fixtures()->createUserSession([]);
api_client()->get(
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06',
$unauthorizedSession['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser_fetch_department']);
$session = api_fixtures()->createUserSession(['superuser_fetch_department']);
api_client()->get('/superuser/departments/bad/overview?date=2026-07-06', $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Parameter id must be a positive integer');
api_client()->get('/superuser/departments/' . $department['id'] . '/overview', $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Missing required parameters: date');
api_client()->get('/superuser/departments/99999999/overview?date=2026-07-06', $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Department not found');
});
@@ -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);
});
@@ -19,6 +19,7 @@ return [
'GET /branding',
'POST /branding',
'PUT /branding',
'GET /superuser/departments/{id}/overview',
'PUT /superuser/department/branding',
'POST /bird/voice/calls/webhook/inbound',
],
@@ -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,
@@ -111,6 +113,46 @@ CREATE TABLE IF NOT EXISTS `department_variables` (
KEY `idx_department_variables_department_id` (`department_id`),
KEY `idx_department_variables_variable` (`variable`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_daily_reports' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_daily_reports` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`water_usage` INT NOT NULL DEFAULT 0,
`water_usage_morning` INT NOT NULL DEFAULT 0,
`notes` TEXT NULL,
`filled_by` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_department_daily_reports_department_id` (`department_id`),
KEY `idx_department_daily_reports_created_at` (`created_at`),
KEY `idx_department_daily_reports_department_created_at` (`department_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_time_bookings_opening_hours' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_time_bookings_opening_hours` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department` INT NOT NULL,
`monday_start` TIME NULL,
`monday_end` TIME NULL,
`tuesday_start` TIME NULL,
`tuesday_end` TIME NULL,
`wednesday_start` TIME NULL,
`wednesday_end` TIME NULL,
`thursday_start` TIME NULL,
`thursday_end` TIME NULL,
`friday_start` TIME NULL,
`friday_end` TIME NULL,
`saturday_start` TIME NULL,
`saturday_end` TIME NULL,
`sunday_start` TIME NULL,
`sunday_end` TIME NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_department_time_bookings_opening_hours_department` (`department`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_gates' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_gates` (
@@ -732,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`),
@@ -892,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
@@ -932,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);
@@ -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.'");
});
@@ -31,8 +31,16 @@ 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:');
expect($content)->toContain('- name: department_ids');
@@ -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,6 +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,50 @@
<?php
it('routes collected invoice draft line uploads through the multi-order batch endpoint', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/objects/collected_order_invoices_o.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
$start = strpos($content, 'public function addInvoicesToDraft');
$end = strpos($content, 'public function getLastEconomicTransferMetrics');
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
expect($end)->toBeGreaterThan($start);
$methodBlock = substr($content, (int)$start, (int)$end - (int)$start);
expect($methodBlock)->toContain('$order_objects = [];')
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);')
->and($methodBlock)->toContain('...$metrics')
->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);');
});
it('keeps single-order draft uploads as a wrapper around the batch endpoint', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
$singleStart = strpos($content, 'public function add_order');
$singleEnd = strpos($content, 'public function add_orders');
expect($singleStart)->not->toBeFalse();
expect($singleEnd)->not->toBeFalse();
expect($singleEnd)->toBeGreaterThan($singleStart);
$singleBlock = substr($content, (int)$singleStart, (int)$singleEnd - (int)$singleStart);
expect($singleBlock)->toContain('$this->add_orders($invoiceDraftId, [$order], $currency);');
$batchBlock = substr($content, (int)$singleEnd);
expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);')
->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines");
});
it('includes collected invoice batch transfer metrics in queue results when available', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/classes/economic_transfer_executor.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();')
->and($content)->toContain("\$result['economic_transfer_metrics'] = \$transfer_metrics;");
});
@@ -0,0 +1,92 @@
<?php
use helpers\economic_invoice_draft;
if (!class_exists('EconomicInvoiceDraftBatchingProbe')) {
class EconomicInvoiceDraftBatchingProbe extends economic_invoice_draft
{
public array $sentBatches = [];
protected function sendDraftLines(array $draft_lines): object
{
$this->sentBatches[] = $draft_lines;
return (object)['lines' => $draft_lines];
}
}
}
if (!class_exists('EconomicInvoiceDraftFailingBatchingProbe')) {
class EconomicInvoiceDraftFailingBatchingProbe extends EconomicInvoiceDraftBatchingProbe
{
public int $failOnBatch = 1;
protected function sendDraftLines(array $draft_lines): object
{
if (count($this->sentBatches) + 1 === $this->failOnBatch) {
throw new RuntimeException('Simulated e-conomic line batch failure');
}
return parent::sendDraftLines($draft_lines);
}
}
}
it('does not call e-conomic when flushing an empty draft line buffer', function (): void {
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
$metrics = $draft->flushLinesInBatches();
expect($metrics)->toBe([
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
])->and($draft->sentBatches)->toBe([]);
});
it('flushes a small draft line buffer in one request and clears pending lines', function (): void {
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
$draft->addTextLine('line-0');
$draft->addTextLine('line-1');
$draft->addTextLine('line-2');
$metrics = $draft->flushLinesInBatches(500);
expect($metrics)->toBe([
'line_count' => 3,
'batch_count' => 1,
'batch_sizes' => [3],
])->and($draft->sentBatches)->toHaveCount(1)
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
->and($draft->sentBatches[0][2]['description'])->toBe('line-2')
->and($draft->pendingLineCount())->toBe(0);
});
it('chunks large draft line buffers while preserving line order', function (): void {
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
for ($i = 0; $i < 1201; $i++) {
$draft->addTextLine('line-' . $i);
}
$metrics = $draft->flushLinesInBatches(500);
expect($metrics)->toBe([
'line_count' => 1201,
'batch_count' => 3,
'batch_sizes' => [500, 500, 201],
])->and($draft->sentBatches)->toHaveCount(3)
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
->and($draft->sentBatches[1][0]['description'])->toBe('line-500')
->and($draft->sentBatches[2][200]['description'])->toBe('line-1200')
->and($draft->pendingLineCount())->toBe(0);
});
it('bubbles line batch failures and keeps pending lines available', function (): void {
$draft = new EconomicInvoiceDraftFailingBatchingProbe(123, 'DKK', true);
$draft->addTextLine('line-0');
expect(fn () => $draft->flushLinesInBatches(500))
->toThrow(RuntimeException::class, 'Simulated e-conomic line batch failure');
expect($draft->sentBatches)->toBe([])
->and($draft->pendingLineCount())->toBe(1);
});
@@ -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();
});
@@ -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')");
});