Compare commits

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

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
Jeppe Bundgaard 0cca597fdc Fix XLVask usage import dates
Fix XLVask usage-log import metadata and period-scoped Selvvask automation.
2026-07-06 23:49:28 +02:00
Jeppe B 709c6acbba Fix product null department permissions
Treats null-like optional product query params as omitted and avoids department_access_0 permission checks.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard ed2736e528 Fix product null department permissions 2026-07-06 19:56:07 +02:00
Jeppe B c7f5c73a9e Merge pull request #303 from copenhagentruckwash/codex/daily-report-product-targets-api
[codex] Add daily report product target API
2026-07-06 19:35:37 +02:00
Jeppe Bundgaard c10af48954 Add daily report product target API 2026-07-06 18:52:24 +02:00
Jeppe Bundgaard 7ac5c5585b Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe B 8544ce0a18 Merge pull request #297 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price overrides
2026-07-06 17:23:06 +02:00
Jeppe Bundgaard 614715822f Fix backend merge fallout for booking and limited employees 2026-07-06 17:16:22 +02:00
Jeppe Bundgaard 1da02e2486 Fix limited backoffice price save reset 2026-07-06 17:13:04 +02:00
Jeppe B 742b15116d Merge pull request #295 from copenhagentruckwash/fix/economic-ean-transfer
Fix e-conomic EAN customer transfer
2026-07-06 17:00:57 +02:00
Jeppe Bundgaard e0ae74bdc2 Merge remote-tracking branch 'origin/master' into codex/customer-product-fixed-price-overrides 2026-07-06 17:00:52 +02:00
Jeppe Bundgaard 08dc803b3e Make limited backoffice employees regular employees 2026-07-06 16:59:32 +02:00
Jeppe Bundgaard 248a901f24 Merge master into fixed price override branch 2026-07-06 16:54:01 +02:00
Jeppe Bundgaard 8bbdf9daf5 Require booking add node for subuser booking creation 2026-07-06 16:48:07 +02:00
Jeppe B c089186046 Merge pull request #302 from copenhagentruckwash/codex/customer-orderbooking-create-without-permission
Allow customer order booking creation without booking permission
2026-07-06 16:39:17 +02:00
Jeppe Bundgaard d9eacf6f84 Deduplicate limited backoffice price products 2026-07-06 16:28:08 +02:00
Jeppe Bundgaard f02dfd8c9c Add customer product fixed price overrides 2026-07-06 12:52:33 +02:00
Jeppe Bundgaard db1b9a2c96 Fix e-conomic EAN customer transfer 2026-07-06 11:34:23 +02:00
61 changed files with 4232 additions and 231 deletions
+120
View File
@@ -40,6 +40,8 @@ tags:
description: Account security and passkey management endpoints description: Account security and passkey management endpoints
- name: Users - name: Users
description: User management and customer operations description: User management and customer operations
- name: Limited Backoffice
description: Limited backoffice employee and department management
- name: Search - name: Search
description: System-wide search endpoints description: System-wide search endpoints
- name: Orders - name: Orders
@@ -2762,6 +2764,44 @@ paths:
properties: properties:
token: {type: string} 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 # User Endpoints
/users: /users:
get: get:
@@ -12405,6 +12445,29 @@ paths:
application/json: application/json:
schema: {} schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions: /roles/permissions:
post: post:
tags: tags:
@@ -12717,6 +12780,33 @@ paths:
schema: schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse' $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: /departments/daily-reports/get:
get: get:
tags: tags:
@@ -21526,6 +21616,36 @@ components:
state: { type: string } state: { type: string }
value: { type: integer } value: { type: integer }
out_of: { 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: DepartmentDailyReportOverviewPayload:
type: object type: object
@@ -137,6 +137,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) { if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400); throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
} }
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
}
} }
protected function normalizePositiveInt(mixed $value): ?int protected function normalizePositiveInt(mixed $value): ?int
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email, string $email,
int $phone, int $phone,
?int $mobile_phone = null, ?int $mobile_phone = null,
object|array|null $company_information = null object|array|null $company_information = null,
?string $ean = null
): object ): object
{ {
$payload = [ $payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
]; ];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information)); $payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
$normalized_ean = self::normalizeCustomerEan($ean);
if ($normalized_ean !== null) {
$payload['ean'] = $normalized_ean;
}
return $this->customers->customers->create($payload); return $this->customers->customers->create($payload);
} }
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{ {
if ($company_information === null) { if ($company_information === null) {
@@ -389,6 +389,15 @@ class economic_v2_distribution_service
} }
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at); $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); $discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) { if ($discount_percentage <= 0) {
continue; continue;
@@ -1520,6 +1529,12 @@ class economic_v2_distribution_service
} }
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp); $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); $discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) { if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100)); $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); $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; return $this->discount_resolution_cache[$cache_key] = $direct;
} }
@@ -65,6 +65,7 @@ class economic_v2_schema_bootstrap
is_category TINYINT(1) NOT NULL, is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL, object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL, discount INT NOT NULL,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL, effective_from DATETIME NOT NULL,
effective_to DATETIME NULL, effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live', source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -83,6 +84,14 @@ class economic_v2_schema_bootstrap
$db->query($sql); $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; self::$initialized = true;
} }
@@ -106,4 +115,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0; return ((int)($row['c'] ?? 0)) > 0;
} }
} }
@@ -135,7 +135,8 @@ class economic_v2_versioning_service
string $source = 'live.discount_override', string $source = 'live.discount_override',
float $confidence = 1.0, float $confidence = 1.0,
bool $inferred = false, bool $inferred = false,
array $metadata = [] array $metadata = [],
?int $fixed_price = null
): array { ): array {
$identity = [ $identity = [
'user_id' => $user_id, 'user_id' => $user_id,
@@ -144,7 +145,7 @@ class economic_v2_versioning_service
'object_id' => (string)$object_id, 'object_id' => (string)$object_id,
]; ];
if ($discount === null || (int)$discount === 0) { if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion( return $this->closeActiveVersion(
'customer_discount_override_versions', 'customer_discount_override_versions',
$identity, $identity,
@@ -161,6 +162,7 @@ class economic_v2_versioning_service
$identity, $identity,
[ [
'discount' => (int)$discount, 'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
], ],
$this->normalizeDatetime($effective_from), $this->normalizeDatetime($effective_from),
$source, $source,
@@ -411,8 +413,11 @@ class economic_v2_versioning_service
} }
// Discount overrides current state. // Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at'); $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' . $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' : ''); ($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll( $discount_rows = $this->fetchAll(
"SELECT $discount_cols "SELECT $discount_cols
@@ -434,7 +439,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override', 'backfill.current_discount_override',
$confidence, $confidence,
true, 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'); $this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
} }
@@ -707,4 +713,3 @@ class economic_v2_versioning_service
$bucket[$action]++; $bucket[$action]++;
} }
} }
@@ -30,6 +30,7 @@ class invoice_period_flag_service
public function __construct() public function __construct()
{ {
invoice_period_flag_schema_bootstrap::ensureTables(); invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
} }
public function createManualFlag(array $payload, int $userId): array public function createManualFlag(array $payload, int $userId): array
@@ -712,6 +713,7 @@ class invoice_period_flag_service
c.name AS category_name, c.name AS category_name,
pdp.price AS department_price, pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage, product_discount.percentage AS product_discount_percentage,
product_discount.fixed_price AS product_fixed_price,
category_discount.percentage AS category_discount_percentage category_discount.percentage AS category_discount_percentage
FROM orders o FROM orders o
LEFT JOIN ( LEFT JOIN (
@@ -726,7 +728,7 @@ class invoice_period_flag_service
LEFT JOIN categories c ON c.id = p.category 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 product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
LEFT JOIN ( 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 FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0 WHERE po.is_category = 0
@@ -1924,10 +1926,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int private function calculateExpectedPrice(array $row): int
{ {
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row); $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 $base = $row['department_price'] !== null
? (int)$row['department_price'] ? (int)$row['department_price']
: ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0)); : (int)($row['product_base_price'] ?? 0);
$discount = $customMissingPrice ? 0 : $this->discountBreakdown($row)['applied_discount_percentage']; $discount = $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100))); return (int)round($base * (1 - ($discount / 100)));
} }
@@ -1945,6 +1956,7 @@ class invoice_period_flag_service
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_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, 'department_price' => $departmentPrice,
'effective_base_price' => $base, 'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'], 'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'], 'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'], 'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1964,15 +1976,25 @@ class invoice_period_flag_service
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1; $categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0; $categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0; $economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [ return [
'product_discount_percentage' => $productDiscount, 'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount, 'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount, '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 private function economicCustomerDiscountPercentage(array $row): int
{ {
$customerNumber = (int)($row['customer_number'] ?? 0); $customerNumber = (int)($row['customer_number'] ?? 0);
@@ -3,6 +3,7 @@
namespace classes; namespace classes;
use mysqli; use mysqli;
use objects\logs_o;
use objects\products_o; use objects\products_o;
use objects\users_o; use objects\users_o;
@@ -12,13 +13,44 @@ class limited_backoffice_service
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage'; public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage'; public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
private const MANAGED_EMPLOYEE_CUSTOMER_NUMBER = 0;
/**
* Permissions required for managed employees to sign in, appear in the employee login picker,
* and open the department admin shell used by their scoped role permissions.
*
* @var array<int, string>
*/
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
'admin',
'user',
'permissions_list_own',
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
];
/**
* Permissions that are always granted to managed employees when present in a role preset,
* regardless of whether the creating manager holds those permissions themselves.
*
* @var array<int, string>
*/
private const ROLE_UNCONDITIONAL_PERMISSIONS = [
'list_departments',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
];
/** /**
* @var array<string, array{label:string,description:string,permissions:array<int,string>}> * @var array<string, array{label:string,description:string,permissions:array<int,string>}>
*/ */
private const ROLE_PRESETS = [ private const ROLE_PRESETS = [
'viewer' => [ 'viewer' => [
'label' => 'Viewer', 'label' => 'Deactivated',
'description' => 'Can sign in and view assigned department data.', 'description' => 'Keeps the employee registered without order, booking, or management permissions.',
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
@@ -26,18 +58,58 @@ class limited_backoffice_service
], ],
'cashier' => [ 'cashier' => [
'label' => 'Cashier', 'label' => 'Cashier',
'description' => 'Can work with orders and order lines for assigned departments.', 'description' => 'Can work with POS orders, products, customers, vehicles, attachments, payments, scanners, and bookings for assigned departments.',
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order',
'add_order', 'add_order',
'edit_order', 'edit_order',
'mark_order_as_completed',
'list_order_items', 'list_order_items',
'add_order_items', 'add_order_items',
'edit_order_items', 'edit_order_items',
'delete_order_items', 'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order', 'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
], ],
], ],
'booking_coordinator' => [ 'booking_coordinator' => [
@@ -46,16 +118,23 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'list_bookings', 'list_bookings',
'list_own_bookings', 'list_own_bookings',
'edit_bookings', 'edit_bookings',
'add_booking', 'add_booking',
'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'department_timebookings_entries_get', 'department_timebookings_entries_get',
'department_timebookings_entries_post', 'department_timebookings_entries_post',
'department_timebookings_entries_put', 'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
], ],
], ],
'operations_lead' => [ 'operations_lead' => [
@@ -64,21 +143,53 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order',
'add_order', 'add_order',
'edit_order', 'edit_order',
'delete_order', 'delete_order',
'mark_order_as_completed',
'list_order_items', 'list_order_items',
'add_order_items', 'add_order_items',
'edit_order_items', 'edit_order_items',
'delete_order_items', 'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order', 'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings', 'list_bookings',
'list_own_bookings', 'list_own_bookings',
'edit_bookings', 'edit_bookings',
'add_booking', 'add_booking',
'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
], ],
@@ -89,21 +200,53 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order',
'add_order', 'add_order',
'edit_order', 'edit_order',
'delete_order', 'delete_order',
'mark_order_as_completed',
'list_order_items', 'list_order_items',
'add_order_items', 'add_order_items',
'edit_order_items', 'edit_order_items',
'delete_order_items', 'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order', 'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings', 'list_bookings',
'list_own_bookings', 'list_own_bookings',
'edit_bookings', 'edit_bookings',
'add_booking', 'add_booking',
'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
self::PERMISSION_ACCESS, self::PERMISSION_ACCESS,
@@ -129,6 +272,10 @@ class limited_backoffice_service
'group' => 'orders', 'group' => 'orders',
'capability' => 'view_orders', 'capability' => 'view_orders',
], ],
'fetch_order' => [
'group' => 'orders',
'capability' => 'view_orders',
],
'add_order' => [ 'add_order' => [
'group' => 'orders', 'group' => 'orders',
'capability' => 'create_orders', 'capability' => 'create_orders',
@@ -141,6 +288,10 @@ class limited_backoffice_service
'group' => 'orders', 'group' => 'orders',
'capability' => 'delete_orders', 'capability' => 'delete_orders',
], ],
'mark_order_as_completed' => [
'group' => 'orders',
'capability' => 'complete_orders',
],
'list_order_items' => [ 'list_order_items' => [
'group' => 'orders', 'group' => 'orders',
'capability' => 'view_order_items', 'capability' => 'view_order_items',
@@ -157,10 +308,110 @@ class limited_backoffice_service
'group' => 'orders', 'group' => 'orders',
'capability' => 'remove_order_lines', 'capability' => 'remove_order_lines',
], ],
'list_order_attachments' => [
'group' => 'attachments',
'capability' => 'view_order_attachments',
],
'add_order_attachments' => [
'group' => 'attachments',
'capability' => 'add_order_attachments',
],
'download_order_attachments' => [
'group' => 'attachments',
'capability' => 'download_order_attachments',
],
'list_products' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_order_recommended' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'vehicle_product_suggestions' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'search_customers' => [
'group' => 'customers',
'capability' => 'search_customers',
],
'get_user_from_customer_number' => [
'group' => 'customers',
'capability' => 'view_customer_details',
],
'list_customer_notes' => [
'group' => 'customers',
'capability' => 'view_customer_notes',
],
'add_customer_note' => [
'group' => 'customers',
'capability' => 'add_customer_notes',
],
'list_customer_attributes' => [
'group' => 'customers',
'capability' => 'view_customer_flags',
],
'search_vehicles' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'view_vehicle_status' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'list_unknown_customer_vehicles' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'list_vehicle_customer_suggestions' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'department_license_plate_lookup' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'department_vehicle_order_last_five' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'list_number_plate_scans' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'list_department_number_plate_scanners' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'charge_order' => [ 'charge_order' => [
'group' => 'orders', 'group' => 'orders',
'capability' => 'charge_orders', 'capability' => 'charge_orders',
], ],
'get_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'confirm_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_department_terminal_readers_list' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_invoice_send' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'list_bookings' => [ 'list_bookings' => [
'group' => 'bookings', 'group' => 'bookings',
'capability' => 'view_department_bookings', 'capability' => 'view_department_bookings',
@@ -177,6 +428,10 @@ class limited_backoffice_service
'group' => 'bookings', 'group' => 'bookings',
'capability' => 'create_bookings', 'capability' => 'create_bookings',
], ],
'add_bookings' => [
'group' => 'bookings',
'capability' => 'create_bookings',
],
'complete_bookings' => [ 'complete_bookings' => [
'group' => 'bookings', 'group' => 'bookings',
'capability' => 'mark_bookings_complete', 'capability' => 'mark_bookings_complete',
@@ -197,6 +452,22 @@ class limited_backoffice_service
'group' => 'time_bookings', 'group' => 'time_bookings',
'capability' => 'edit_time_booking_entries', 'capability' => 'edit_time_booking_entries',
], ],
'list_departments' => [
'group' => 'departments',
'capability' => 'view_departments',
],
'list_department_daily_reports' => [
'group' => 'departments',
'capability' => 'view_daily_reports',
],
'list_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'list_own_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'statistics_orders_new' => [ 'statistics_orders_new' => [
'group' => 'reports', 'group' => 'reports',
'capability' => 'view_order_statistics', 'capability' => 'view_order_statistics',
@@ -224,9 +495,16 @@ class limited_backoffice_service
*/ */
private const ROLE_PERMISSION_GROUP_ORDER = [ private const ROLE_PERMISSION_GROUP_ORDER = [
'account', 'account',
'departments',
'orders', 'orders',
'products',
'customers',
'vehicles',
'attachments',
'scanner',
'bookings', 'bookings',
'time_bookings', 'time_bookings',
'notifications',
'reports', 'reports',
'limited_backoffice', 'limited_backoffice',
]; ];
@@ -255,20 +533,41 @@ class limited_backoffice_service
/** /**
* @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,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 = []; $roles = [];
foreach (self::ROLE_PRESETS as $key => $preset) { foreach (self::ROLE_PRESETS as $key => $preset) {
$permissions = $manager === null
? $preset['permissions']
: $this->effectiveRolePermissionsForManager($manager, $key, false);
$roles[] = [ $roles[] = [
'key' => $key, 'key' => $key,
'label' => $preset['label'], 'label' => $preset['label'],
'description' => $preset['description'], 'description' => $preset['description'],
'permission_groups' => $this->rolePermissionGroups($preset['permissions']), 'permission_groups' => $this->rolePermissionGroups($permissions),
]; ];
} }
return $roles; return $roles;
} }
/**
* @return array<int, array{key:string,label:string,description:string,permissions:array<int,string>}>
*/
public function rolePermissionTemplates(): array
{
$templates = [];
foreach (self::ROLE_PRESETS as $key => $preset) {
$templates[] = [
'key' => $key,
'label' => $preset['label'],
'description' => $preset['description'],
'permissions' => array_values($preset['permissions']),
];
}
return $templates;
}
/** /**
* @param array<int, string> $permissions * @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}> * @return array<int, array{key:string,capabilities:array<int,string>}>
@@ -316,6 +615,15 @@ class limited_backoffice_service
return []; return [];
} }
if ($user->hasPermission('superuser')) {
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
}
global $db; global $db;
$statement = $this->mysqli()->prepare( $statement = $this->mysqli()->prepare(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?' 'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?'
@@ -458,26 +766,26 @@ class limited_backoffice_service
$mysqli->begin_transaction(); $mysqli->begin_transaction();
try { try {
$priceUpdateAssignments = ['`price` = VALUES(`price`)']; $deleteStatement = $mysqli->prepare(
if ($this->tableHasColumn('product_department_prices', 'updated_at')) { 'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
$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)
); );
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.'); throw new \RuntimeException('Unable to prepare department price update.');
} }
foreach ($normalizedPrices as $productId => $price) { foreach ($normalizedPrices as $productId => $price) {
$statement->bind_param('iii', $departmentId, $productId, $price); $deleteStatement->bind_param('ii', $departmentId, $productId);
$statement->execute(); $deleteStatement->execute();
$insertStatement->bind_param('iii', $departmentId, $productId, $price);
$insertStatement->execute();
} }
$statement->close(); $deleteStatement->close();
$insertStatement->close();
$mysqli->commit(); $mysqli->commit();
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
$mysqli->rollback(); $mysqli->rollback();
@@ -557,7 +865,7 @@ class limited_backoffice_service
try { try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds); $groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$customerNumber = $this->generateEmployeeCustomerNumber(); $customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
$passwordHash = password_hash($password, PASSWORD_DEFAULT); $passwordHash = password_hash($password, PASSWORD_DEFAULT);
$statement = $mysqli->prepare( $statement = $mysqli->prepare(
@@ -582,15 +890,7 @@ class limited_backoffice_service
$employeeId = (int)$mysqli->insert_id; $employeeId = (int)$mysqli->insert_id;
$statement->close(); $statement->close();
$groupName = 'Limited employee #' . $employeeId; $this->renameManagedGroup($groupId, $employeeId);
$groupDescription = 'Managed by limited backoffice.';
$statement = $mysqli->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) { if (!is_string($departmentJson)) {
@@ -624,6 +924,72 @@ class limited_backoffice_service
return $this->formatEmployee($employee, $departmentIds, true); return $this->formatEmployee($employee, $departmentIds, true);
} }
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function migrateEmployee(users_o $manager, int $employeeId, array $payload): array
{
$this->rejectRawPermissionPayload($payload);
$this->assertNotSelfEdit($manager, $employeeId);
if ($this->loadManagedEmployee($employeeId) !== null) {
throw new limited_backoffice_exception('User is already a limited backoffice employee.', 409);
}
$target = $this->loadMigratableUser($employeeId);
if ($target === null) {
throw new limited_backoffice_exception('User not found.', 404);
}
$this->assertMigrationTargetIsSafe($target);
$departmentIds = $this->normalizeDepartmentIds($payload['department_ids'] ?? null);
$this->assertDepartmentSubset($manager, $departmentIds);
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) {
throw new limited_backoffice_exception('Unable to encode department metadata.', 500);
}
$mysqli = $this->mysqli();
$mysqli->begin_transaction();
try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$this->renameManagedGroup($groupId, $employeeId);
$this->updateUserFields($employeeId, [
'group_id' => $groupId,
]);
$managerId = (int)$manager->id;
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`, `updated_by_user_id`)
VALUES (?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare migrated employee metadata insert.');
}
$statement->bind_param('iissii', $employeeId, $groupId, $roleKey, $departmentJson, $managerId, $managerId);
$statement->execute();
$statement->close();
$this->clearUserSessionCache($employeeId);
$mysqli->commit();
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to migrate employee.', 500);
}
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Unable to load migrated employee.', 500);
}
return $this->formatEmployee($employee, $departmentIds, true);
}
/** /**
* @param array<string, mixed> $payload * @param array<string, mixed> $payload
* @return array<string, mixed> * @return array<string, mixed>
@@ -677,7 +1043,7 @@ class limited_backoffice_service
try { try {
if ($active) { if ($active) {
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($roleKey, $newDepartmentIds)); $this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
} }
$userUpdates = []; $userUpdates = [];
@@ -750,6 +1116,47 @@ class limited_backoffice_service
return $this->updateEmployee($manager, $employeeId, ['active' => false]); 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 private function mysqli(): mysqli
{ {
global $db; global $db;
@@ -852,8 +1259,14 @@ class limited_backoffice_service
INNER JOIN `categories` c ON c.`id` = dc.`category_id` INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id` INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id` ON pdp.`id` = (
AND pdp.`product_id` = p.`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) . ' WHERE ' . implode(' AND ', $where) . '
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC' ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC'
); );
@@ -869,9 +1282,15 @@ class limited_backoffice_service
$categories = []; $categories = [];
$missing = []; $missing = [];
$requiredProductIds = []; $requiredProductIds = [];
$seenProductIds = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$categoryId = (int)$row['category_id']; $categoryId = (int)$row['category_id'];
$productId = (int)$row['product_id']; $productId = (int)$row['product_id'];
if (isset($seenProductIds[$productId])) {
continue;
}
$seenProductIds[$productId] = true;
$requiredProductIds[] = $productId; $requiredProductIds[] = $productId;
if (!isset($categories[$categoryId])) { if (!isset($categories[$categoryId])) {
@@ -911,7 +1330,7 @@ class limited_backoffice_service
return [ return [
'categories' => array_values($categories), 'categories' => array_values($categories),
'missing_products' => $missing, 'missing_products' => $missing,
'required_product_ids' => array_values(array_unique($requiredProductIds)), 'required_product_ids' => array_values($requiredProductIds),
]; ];
} }
@@ -1220,18 +1639,57 @@ class limited_backoffice_service
$groupId = (int)$this->mysqli()->insert_id; $groupId = (int)$this->mysqli()->insert_id;
$statement->close(); $statement->close();
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($roleKey, $departmentIds)); $this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $departmentIds));
return $groupId; return $groupId;
} }
private function renameManagedGroup(int $groupId, int $employeeId): void
{
$groupName = 'Limited employee #' . $employeeId;
$groupDescription = 'Managed by limited backoffice.';
$statement = $this->mysqli()->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
}
/**
* @return array<int, string>
*/
private function effectiveRolePermissionsForManager(users_o $manager, string $roleKey, bool $includePublicVisibility): array
{
$permissions = $includePublicVisibility ? self::MANAGED_EMPLOYEE_BASE_PERMISSIONS : ['user'];
foreach (self::ROLE_PRESETS[$roleKey]['permissions'] ?? [] as $permission) {
if (in_array($permission, self::MANAGED_EMPLOYEE_BASE_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if (in_array($permission, self::ROLE_UNCONDITIONAL_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if ($manager->hasPermission($permission)) {
$permissions[] = $permission;
}
}
return array_values(array_unique($permissions));
}
/** /**
* @param array<int, int> $departmentIds * @param array<int, int> $departmentIds
* @return array<int, string> * @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) { foreach ($departmentIds as $departmentId) {
$permissions[] = 'department_access_' . $departmentId; $permissions[] = 'department_access_' . $departmentId;
} }
@@ -1273,29 +1731,6 @@ class limited_backoffice_service
$insert->close(); $insert->close();
} }
private function generateEmployeeCustomerNumber(): int
{
$mysqli = $this->mysqli();
for ($attempt = 0; $attempt < 20; $attempt++) {
$customerNumber = random_int(900000000, 999999999);
$statement = $mysqli->prepare('SELECT `id` FROM `users` WHERE `customer_number` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare customer number check.');
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$exists = $result->num_rows > 0;
$statement->close();
if (!$exists) {
return $customerNumber;
}
}
throw new \RuntimeException('Unable to generate employee customer number.');
}
/** /**
* @return array<string, mixed>|null * @return array<string, mixed>|null
*/ */
@@ -1333,6 +1768,42 @@ class limited_backoffice_service
return is_array($row) ? $row : null; return is_array($row) ? $row : null;
} }
/**
* @return array<string, mixed>|null
*/
private function loadMigratableUser(int $employeeId): ?array
{
global $db;
$userDeletedAtSelect = $this->tableHasColumn('users', 'deleted_at')
? 'u.`deleted_at` AS `user_deleted_at`'
: 'NULL AS `user_deleted_at`';
$statement = $this->mysqli()->prepare(
'SELECT
u.`id`,
u.`customer_number`,
u.`display_name`,
u.`email`,
u.`phone_country_code`,
u.`phone`,
u.`group_id`,
' . $userDeletedAtSelect . '
FROM `users` u
WHERE u.`id` = ?
LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to load user.', 500);
}
$statement->bind_param('i', $employeeId);
$statement->execute();
$result = $statement->get_result();
$row = $db->fetch_assoc($result);
$statement->close();
return is_array($row) ? $row : null;
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
*/ */
@@ -1350,6 +1821,7 @@ class limited_backoffice_service
{ {
return [ return [
'id' => (int)$row['user_id'], 'id' => (int)$row['user_id'],
'user_id' => (int)$row['user_id'],
'customer_number' => (int)$row['customer_number'], 'customer_number' => (int)$row['customer_number'],
'display_name' => (string)($row['display_name'] ?? ''), 'display_name' => (string)($row['display_name'] ?? ''),
'email' => $row['email'] === null ? null : (string)$row['email'], 'email' => $row['email'] === null ? null : (string)$row['email'],
@@ -1426,6 +1898,25 @@ class limited_backoffice_service
} }
} }
/**
* @param array<string, mixed> $target
*/
private function assertMigrationTargetIsSafe(array $target): void
{
if ((int)($target['customer_number'] ?? -1) !== self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER) {
throw new limited_backoffice_exception('Only employee accounts with customer number 0 can be migrated.', 400);
}
$groupId = (int)($target['group_id'] ?? 0);
if ($groupId === 1 || $this->groupHasPermission($groupId, 'superuser')) {
throw new limited_backoffice_exception('Cannot migrate superuser accounts.', 403);
}
if (($target['user_deleted_at'] ?? null) !== null) {
throw new limited_backoffice_exception('Cannot migrate inactive users.', 409);
}
}
private function groupHasPermission(int $groupId, string $permission): bool private function groupHasPermission(int $groupId, string $permission): bool
{ {
if ($groupId <= 0) { if ($groupId <= 0) {
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for customer product price overrides.
*/
class price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureColumns(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'price_overrides')) {
return;
}
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
$db->query(
"ALTER TABLE price_overrides
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER percentage"
);
}
self::$initialized = true;
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function escapeIdentifier(string $value): string
{
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
}
}
@@ -499,6 +499,7 @@ class system_search_document_index
*/ */
private function buildCustomerDiscountDocuments(): array private function buildCustomerDiscountDocuments(): array
{ {
price_overrides_schema_bootstrap::ensureColumns();
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
$selectFields = [ $selectFields = [
'po.id AS entity_id', 'po.id AS entity_id',
@@ -506,6 +507,7 @@ class system_search_document_index
'po.is_category', 'po.is_category',
'po.product_or_category_id', 'po.product_or_category_id',
'po.percentage', 'po.percentage',
'po.fixed_price',
'u.customer_number', 'u.customer_number',
'u.display_name', 'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'), ...$this->joinTemporalSelectFields('price_overrides', 'po'),
@@ -554,6 +556,7 @@ class system_search_document_index
$row['search_text'] ?? null, $row['search_text'] ?? null,
$row['product_or_category_id'] ?? null, $row['product_or_category_id'] ?? null,
$row['percentage'] ?? null, $row['percentage'] ?? null,
$row['fixed_price'] ?? null,
$row['user_id'] ?? null, $row['user_id'] ?? null,
]), ]),
$this->toIntOrNull($row['customer_number'] ?? null), $this->toIntOrNull($row['customer_number'] ?? null),
@@ -564,6 +567,7 @@ class system_search_document_index
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
'product_or_category_id' => $row['product_or_category_id'] ?? null, 'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => $this->toIntOrNull($row['percentage'] ?? null), 'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null, 'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? 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 private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
{ {
price_overrides_schema_bootstrap::ensureColumns();
$customerFilter = ''; $customerFilter = '';
if (!empty($forcedCustomerNumbers)) { if (!empty($forcedCustomerNumbers)) {
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
@@ -1100,11 +1101,12 @@ class system_search_service
'po.is_category', 'po.is_category',
'po.product_or_category_id', 'po.product_or_category_id',
'po.percentage', 'po.percentage',
'po.fixed_price',
'u.customer_number', 'u.customer_number',
'u.display_name', 'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'), ...$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()) { if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; $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', 'search_text',
'product_or_category_id', 'product_or_category_id',
'percentage', 'percentage',
'fixed_price',
'user_id', 'user_id',
], $terms) + $entityBoost, ], $terms) + $entityBoost,
'payload' => $this->augmentPayloadWithTemporal([ 'payload' => $this->augmentPayloadWithTemporal([
@@ -1173,6 +1176,7 @@ class system_search_service
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
'product_or_category_id' => $row['product_or_category_id'] ?? null, 'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : 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_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null,
], $row), ], $row),
@@ -1246,19 +1246,20 @@ class xlvask_automation_service
{ {
global $db; global $db;
(new xlvask_usage_logs_o())->structure(); (new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = [ $where = [
'FinishStatus = 1', 'FinishStatus = 1',
'(ignored_at IS NULL OR ignored_at = "")', '(ignored_at IS NULL OR ignored_at = "")',
]; ];
if ($dateFrom !== null && strtotime($dateFrom) !== false) { 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 { } 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) { 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)); $limit = max(1, min(500, $limit));
@@ -14,6 +14,8 @@ class economic_customer_mo
public null|string $message; public null|string $message;
public null|string $corporateIdentificationNumber; public null|string $corporateIdentificationNumber;
public null|string $email; public null|string $email;
public null|string $ean;
public null|string $publicEntryNumber;
public null|string $mobilePhone; public null|string $mobilePhone;
public null|string $currency; public null|string $currency;
public null|string $country; public null|string $country;
@@ -46,6 +48,8 @@ class economic_customer_mo
$this->zip = ($customer->zip ?? null); $this->zip = ($customer->zip ?? null);
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null); $this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
$this->email = ($customer->email ?? null); $this->email = ($customer->email ?? null);
$this->ean = ($customer->ean ?? null);
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
$this->mobilePhone = ($customer->mobilePhone ?? null); $this->mobilePhone = ($customer->mobilePhone ?? null);
$this->currency = ($customer->currency ?? null); $this->currency = ($customer->currency ?? null);
$this->country = ($customer->country ?? null); $this->country = ($customer->country ?? null);
@@ -100,6 +104,8 @@ class economic_customer_mo
'zip' => $this->zip, 'zip' => $this->zip,
'corporateIdentificationNumber' => $this->corporateIdentificationNumber, 'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
'email' => $this->email, 'email' => $this->email,
'ean' => $this->ean,
'publicEntryNumber' => $this->publicEntryNumber,
'mobilePhone' => $this->mobilePhone, 'mobilePhone' => $this->mobilePhone,
'currency' => $this->currency, 'currency' => $this->currency,
'country' => $this->country, 'country' => $this->country,
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
$customer_address = $customer->getAddress() ?? 'Ukendt'; $customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt'; $customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt'; $customer_city = $customer->getCity() ?? 'Ukendt';
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number;
}
// Send the request // Send the request
$response = $this->send_request( $response = $this->send_request(
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
'currency' => $customer->getCurrency() ?? 'DKK', 'currency' => $customer->getCurrency() ?? 'DKK',
// Set the recipient details // Set the recipient details
'recipient' => [ 'recipient' => $recipient,
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
],
]) ])
); );
// Return the response as an object // Return the response as an object
@@ -149,6 +149,29 @@ class economic_customer
return $this->customer_data_object->email; return $this->customer_data_object->email;
} }
public function getEan(): ?string
{
self::requireSelected();
return $this->nullableStringField('ean');
}
public function getPublicEntryNumber(): ?string
{
self::requireSelected();
return $this->nullableStringField('publicEntryNumber');
}
protected function nullableStringField(string $field): ?string
{
$value = $this->customer_data_object->{$field} ?? null;
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/** /**
* Get the customer address * Get the customer address
* @return string The customer address * @return string The customer address
@@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper
private function unsetNullifiableProperties(): void private function unsetNullifiableProperties(): void
{ {
$nullable_review_metadata = [
'ignored_at',
'ignored_reason',
];
// Unset properties that are null or empty strings // Unset properties that are null or empty strings
$properties = [ $properties = [
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', 'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
if ($this->isEmptyOrDefault($this->{$property})) { if ($this->isEmptyOrDefault($this->{$property})) {
$tmp_value = $this->{$property}; $tmp_value = $this->{$property};
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) { 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) { } elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
if ($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 $this->{$property} = null; // Set to null if it matches the default int nullable
+3 -4
View File
@@ -177,13 +177,12 @@ class order_items_o extends db
// Check if the user has a discount on the product, or category // Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id); $customer = (new orders_o())->getOrderCustomer($order_id);
$discount = $customer->getCustomPrice($product_id, false); if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
if ($discount && !products_o::priceResolutionIsCustomMissing($priceResolution)) { $price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
$price = $price - ($price * $discount / 100);
} }
// If the price is forced, set the price to the forced price // If the price is forced, set the price to the forced price
if ($forcePrice) { if ($forcePrice !== null) {
$price = (int)$forcePrice; $price = (int)$forcePrice;
} }
+3 -9
View File
@@ -1435,10 +1435,8 @@ class orders_o extends db
if (!$user->exists()) { if (!$user->exists()) {
throw new Exception('No user found matching the customer number in the usage log'); 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
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price $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->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->price->set((int)$product_price); // Set the price based on the product price and discount percentage
@@ -1508,13 +1506,10 @@ class orders_o extends db
} }
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value()); $priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$price = (int)$priceResolution['price']; // Get the department price for the product $price = (int)$priceResolution['price']; // 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
if (products_o::priceResolutionIsCustomMissing($priceResolution)) { if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price; return $price;
} }
return (int)round($price * (1 - ($discount_percentage / 100))); return $current_user->applyProductCustomerPricing((int)$product->id, $price);
} }
/** /**
@@ -1601,10 +1596,9 @@ class orders_o extends db
if ($tmp_user === null) { if ($tmp_user === null) {
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); $tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
} }
$discount = $tmp_user->getCustomPrice($product_id, false);
$unitPrice = (int)$department_price_cache[$product_id]['price']; $unitPrice = (int)$department_price_cache[$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) { if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
$unitPrice = (int)round($unitPrice * (1 - ($discount / 100))); $unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false);
} }
$post_discount = $unitPrice * $quantity; $post_discount = $unitPrice * $quantity;
$total += $post_discount; $total += $post_discount;
+2 -5
View File
@@ -339,11 +339,8 @@ class products_o extends db
if (!isset($product['id']) || !isset($product['price'])) { if (!isset($product['id']) || !isset($product['price'])) {
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys'); throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
} }
// Get the customer's discount percentage if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$discount_percentage = $customer->getProductDiscountPercentage($product['id']); $product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
// Apply the discount to the product price
if ($discount_percentage > 0 && ($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
} }
unset($product[self::PRICE_SOURCE_KEY]); unset($product[self::PRICE_SOURCE_KEY]);
return $product; return $product;
@@ -4,6 +4,8 @@ namespace objects;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
use classes\price_overrides_schema_bootstrap;
use classes\system_search_cache;
use traits\db_object_t; use traits\db_object_t;
class user_price_overrides_o extends db 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 $is_category;
public object_property $product_or_category_id; public object_property $product_or_category_id;
public object_property $percentage; public object_property $percentage;
public object_property $fixed_price;
public function structure(): void public function structure(): void
{ {
$this->setTable('price_overrides'); $this->setTable('price_overrides');
price_overrides_schema_bootstrap::ensureColumns();
} }
public function objectChanged(): void 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->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->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->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 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 bool $is_category
* @param int|string $product_or_category_id * @param int|string $product_or_category_id
* @param int $percentage * @param int $percentage
* @param int|null $fixed_price
* @return $this * @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; global $db;
// If the user is not set, return the object // If the user is not set, return the object
if (!isset($this->user_id)) { if (!isset($this->user_id)) {
return $this; return $this;
} }
if ($is_category) {
$fixed_price = null;
}
// Check if the record already exists // Check if the record already exists
$this->removePriceIfExist($is_category, $product_or_category_id); $this->removePriceIfExist($is_category, $product_or_category_id);
// If the percentage is 0, return the object // If neither a discount nor a fixed product price is set, remove the record.
if ($percentage === 0) { if ($percentage === 0 && $fixed_price === null) {
return $this; return $this;
} }
// Create a new record in the database // 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); $db->query($sql);
$this->markSearchDirty();
return $this; return $this;
} }
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
{ {
global $db; global $db;
// Get the price override from the database $product_or_category_id = $db->escape_string((string)$product_or_category_id);
$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'"; $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'";
$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); $db->query($sql);
} $this->markSearchDirty();
} }
/** /**
@@ -132,6 +139,49 @@ class user_price_overrides_o extends db
return $percentage; 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 * Get all the price overrides for the user
* @return array * @return array
@@ -153,6 +203,7 @@ class user_price_overrides_o extends db
$row['is_category'] = (bool)$row['is_category']; $row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = (int)$row['product_or_category_id']; $row['product_or_category_id'] = (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage']; $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['created_at'] = (string)$row['created_at'];
$row['updated_at'] = (string)$row['updated_at']; $row['updated_at'] = (string)$row['updated_at'];
// Add the row to the list // Add the row to the list
@@ -167,10 +218,19 @@ class user_price_overrides_o extends db
'is_category' => true, 'is_category' => true,
'product_or_category_id' => "global", 'product_or_category_id' => "global",
'percentage' => (int)$economic_user_global_discount, 'percentage' => (int)$economic_user_global_discount,
'fixed_price' => null,
'created_at' => "2021-01-01 00:00:00", 'created_at' => "2021-01-01 00:00:00",
'updated_at' => "2021-01-01 00:00:00" 'updated_at' => "2021-01-01 00:00:00"
]; ];
} }
return $prices; 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; 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; 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 public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
{ {
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) { 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']); $listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
} }
return $listObjectsWithPaginationIfSet; return $listObjectsWithPaginationIfSet;
@@ -1183,15 +1264,16 @@ class users_o extends db
* @param int $object_id The ID of the object * @param int $object_id The ID of the object
* @param int $discount_percentage The discount percentage * @param int $discount_percentage The discount percentage
* @param bool $is_category If the object is a category * @param bool $is_category If the object is a category
* @param int|null $fixed_price The fixed product price, when set
* @return void * @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; $this->id = $user_id;
// Get the user object properties // Get the user object properties
$this->getObjectProperties(); $this->getObjectProperties();
// Set the custom price (key = 'custom_price') // 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 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->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', '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->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_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); $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 * 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. * @throws Exception If the objects were not successfully added.
* @returns void * @returns void
*/ */
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
{ {
if (!empty($this->id)) { if (!empty($this->id)) {
throw new Exception('To prevent issues, having a selected object is not allowed.'); throw new Exception('To prevent issues, having a selected object is not allowed.');
} }
$usage_logs = $this->getUsageLogsFromXLVask( $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 */ /** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
$known_usage_logIds = array_map(function ($log) { $known_usage_logIds = array_map(function ($log) {
return $log['WashId']; return $log['WashId'];
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
unset($new_usage_logs); 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 * 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') * @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
+99 -2
View File
@@ -2595,6 +2595,12 @@ paths:
type: string type: string
description: Contact person name description: Contact person name
example: "Mikkel" example: "Mikkel"
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
g_recaptcha_response: g_recaptcha_response:
type: string type: string
description: reCAPTCHA verification token description: reCAPTCHA verification token
@@ -3092,7 +3098,7 @@ paths:
get: get:
tags: tags:
- Users - Users
summary: Get user discounts summary: Get user discounts and product fixed prices
operationId: getUserDiscounts operationId: getUserDiscounts
parameters: parameters:
- name: user_id - name: user_id
@@ -3108,7 +3114,7 @@ paths:
post: post:
tags: tags:
- Users - Users
summary: Set user discount summary: Set user discount or product fixed price
operationId: setUserDiscount operationId: setUserDiscount
requestBody: requestBody:
required: true required: true
@@ -3122,6 +3128,11 @@ paths:
discount: {type: integer} discount: {type: integer}
object_id: {type: string} object_id: {type: string}
is_category: {type: boolean} 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: responses:
'200': '200':
description: Success description: Success
@@ -8653,6 +8664,12 @@ paths:
email: {type: string} email: {type: string}
phone: {type: integer} phone: {type: integer}
name: {type: string} name: {type: string}
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
responses: responses:
'200': '200':
description: Success description: Success
@@ -12405,6 +12422,29 @@ paths:
application/json: application/json:
schema: {} schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions: /roles/permissions:
post: post:
tags: tags:
@@ -12751,6 +12791,33 @@ paths:
schema: schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse' $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: /departments/daily-reports/get:
get: get:
tags: tags:
@@ -21560,6 +21627,36 @@ components:
state: { type: string } state: { type: string }
value: { type: integer } value: { type: integer }
out_of: { 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: DepartmentDailyReportOverviewPayload:
type: object type: object
@@ -1733,13 +1733,13 @@ class InvoicingPeriodRoute
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id); $department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
} }
if (!array_key_exists($product_id, $discount_cache)) { 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']; $unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) { if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
$unit_price = (int)round($unit_price * (1 - ($discount_cache[$product_id] / 100))); $unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false);
} }
$post_discount = $unit_price * $quantity; $discount_cache[$product_id] = $unit_price;
}
$post_discount = (int)$discount_cache[$product_id] * $quantity;
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount); $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
} }
+9
View File
@@ -427,6 +427,7 @@ class authRoute
$contactEmail = self::getParameter('contactEmail'); $contactEmail = self::getParameter('contactEmail');
$contactPhone = (int)self::getParameter('contactPhone'); $contactPhone = (int)self::getParameter('contactPhone');
$contactName = self::getParameter('contactName'); $contactName = self::getParameter('contactName');
$ean = null;
/** /**
* Validate * Validate
*/ */
@@ -454,6 +455,13 @@ class authRoute
self::requireMinValue($contactPhone, 10000000); self::requireMinValue($contactPhone, 10000000);
self::requireMaxValue($contactPhone, 9999999999); self::requireMaxValue($contactPhone, 9999999999);
} }
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
/** /**
* If the contact phone is empty, default to company phone * If the contact phone is empty, default to company phone
@@ -545,6 +553,7 @@ class authRoute
(int)$companyPhone, (int)$companyPhone,
(int)$contactPhone, (int)$contactPhone,
$companyInformation, $companyInformation,
$ean,
); );
} catch (Exception $exception) { } catch (Exception $exception) {
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure( $recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
@@ -13,6 +13,7 @@ use DateTimeZone;
use Exception; use Exception;
use objects\department_daily_report_complaints_o; use objects\department_daily_report_complaints_o;
use objects\department_daily_reports_o; use objects\department_daily_reports_o;
use objects\department_variables_o;
use objects\departments_o; use objects\departments_o;
use objects\logs_o; use objects\logs_o;
use objects\users_o; use objects\users_o;
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
{ {
use route_t; use route_t;
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
public function run(): void public function run(): void
{ {
$this->get('/departments/daily-reports', function () { $this->get('/departments/daily-reports', function () {
@@ -850,7 +853,8 @@ class departmentDailyReportsRoute
'overview' => $this->buildDailyReportOverview( 'overview' => $this->buildDailyReportOverview(
[$department_id], [$department_id],
(string)self::getParameter('date'), (string)self::getParameter('date'),
$date_to $date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
), ),
]); ]);
}, },
@@ -895,7 +899,8 @@ class departmentDailyReportsRoute
$this->buildDailyReportOverview( $this->buildDailyReportOverview(
$department_ids, $department_ids,
(string)self::getParameter('date'), (string)self::getParameter('date'),
$date_to $date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
) )
); );
}, },
@@ -906,6 +911,73 @@ class departmentDailyReportsRoute
] ]
); );
$this->put('/departments/daily-reports/product-targets', function () {
global $response;
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
'department_id',
'product_id',
'target_percentage',
]);
$department_id = (int)self::getParameter('department_id');
if ($department_id <= 0) {
$response->error('Parameter department_id must be a positive integer', 400);
return;
}
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
self::requireDepartmentAccess($department_id);
$product_id = (int)self::getParameter('product_id');
if (!$this->isDailyReportProductId($product_id)) {
$response->error('Invalid daily report product_id', 400);
return;
}
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
if (!$parsed_target['valid']) {
$response->error($parsed_target['message'], 400);
return;
}
$target_percentage = $parsed_target['value'];
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$target_key = $this->dailyReportProductTargetVariableKey($product_id);
if ($target_percentage === null) {
$this->clearDailyReportProductTarget($department_variables, $target_key);
} else {
$department_variables->set($target_key, number_format($target_percentage, 1, '.', ''));
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'Successfully updated department daily report product target');
$response->success([
'department_id' => $department_id,
'product_id' => $product_id,
'target_percentage' => $target_percentage,
]);
},
[
self::SET_PRODUCT_TARGET_PERMISSION => 'Set department daily report product target percentages',
'department_access_:department_id' => 'Access the department'
]
);
$this->get('/departments/daily-reports/product-count', function () { $this->get('/departments/daily-reports/product-count', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
@@ -1369,7 +1441,7 @@ class departmentDailyReportsRoute
* } * }
* @throws Exception * @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(); $repository = $this->dailyReportRepository();
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to); $transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
@@ -1389,6 +1461,11 @@ class departmentDailyReportsRoute
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to); $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( return $this->assembleDailyReportOverview(
$department_ids, $department_ids,
$date, $date,
@@ -1399,7 +1476,8 @@ class departmentDailyReportsRoute
$product_summary_lookup, $product_summary_lookup,
$complaints_metric, $complaints_metric,
$night_wash_metric, $night_wash_metric,
$overtime_metric $overtime_metric,
$product_target_lookup
); );
} }
@@ -1412,6 +1490,7 @@ class departmentDailyReportsRoute
* @param array<string,mixed> $complaints_metric * @param array<string,mixed> $complaints_metric
* @param array<string,mixed> $night_wash_metric * @param array<string,mixed> $night_wash_metric
* @param array<string,mixed> $overtime_metric * @param array<string,mixed> $overtime_metric
* @param array<int,float> $product_target_lookup
* @return array{ * @return array{
* department_ids:array<int>, * department_ids:array<int>,
* date:string, * date:string,
@@ -1430,7 +1509,8 @@ class departmentDailyReportsRoute
array $product_summary_lookup, array $product_summary_lookup,
array $complaints_metric, array $complaints_metric,
array $night_wash_metric, array $night_wash_metric,
array $overtime_metric array $overtime_metric,
array $product_target_lookup = []
): array { ): array {
$products = []; $products = [];
foreach ($product_definitions as $definition) { foreach ($product_definitions as $definition) {
@@ -1448,6 +1528,12 @@ class departmentDailyReportsRoute
'state' => 'ready', 'state' => 'ready',
'value' => (int)($product_summary['quantity'] ?? 0), 'value' => (int)($product_summary['quantity'] ?? 0),
'out_of' => (int)($product_summary['out_of'] ?? 0), 'out_of' => (int)($product_summary['out_of'] ?? 0),
'target_percentage' => array_key_exists($product_id, $product_target_lookup)
? (float)$product_target_lookup[$product_id]
: null,
'target_department_id' => array_key_exists($product_id, $product_target_lookup)
? (int)$department_ids[0]
: null,
]; ];
} }
@@ -1506,6 +1592,87 @@ class departmentDailyReportsRoute
return array_values($normalized); 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}> * @return array<int,array{product_id:int,slug:string,title:string}>
*/ */
@@ -45,10 +45,10 @@ class limitedBackofficeRoute
]); ]);
$this->get('/limited-backoffice/roles', function () { $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_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); $this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->rolePresets(); return $service->rolePresets($user);
}); });
}, [ }, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
@@ -78,6 +78,26 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees', limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]); ]);
$this->post('/limited-backoffice/employees/{employeeId}/migrate', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission('superuser');
return $service->migrateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
});
}, [
'superuser' => 'Migrate existing employees to limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->put('/limited-backoffice/employees/{employeeId}', function () { $this->put('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -60,6 +60,14 @@ class moduleEconomicCustomerRoute
self::requireMaxLength('phone', 255); self::requireMaxLength('phone', 255);
self::requireMinLength('name', 1); self::requireMinLength('name', 1);
self::requireMaxLength('name', 255); self::requireMaxLength('name', 255);
$ean = null;
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
(new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer'); (new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer');
$result = (new economic())->createCustomer( $result = (new economic())->createCustomer(
(int)self::getParameter('customer_number'), (int)self::getParameter('customer_number'),
@@ -67,6 +75,9 @@ class moduleEconomicCustomerRoute
(int)self::getParameter('cvr'), (int)self::getParameter('cvr'),
(string)self::getParameter('email'), (string)self::getParameter('email'),
(int)self::getParameter('phone'), (int)self::getParameter('phone'),
null,
null,
$ean,
); );
$response->success((object)$result); $response->success((object)$result);
} else { } else {
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/import-usage', function () { $this->get('/modules/xlvask/tasks/import-usage', function () {
global $response; global $response;
self::requirePermission('modules_xlvask_import_usage'); 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 // Create the xlvask_usage_logs_o object
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o(); $xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
// Import usage logs // Import usage logs
$xlvask_usage_logs_o->importUsageLogs(); $xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
(new xlvask_automation_service())->runPending(null, null, [], 100, null); (new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
// Response // Response
$response->success( $response->success(
'Usage logs imported', 'Usage logs imported',
@@ -87,7 +87,8 @@ class orderBookingRoute
$response->success($order_bookings_o->asArray()); $response->success($order_bookings_o->asArray());
}, },
[ [
'add_bookings' => 'Permission to create order bookings for another customer or department scope.' '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.'
] ]
); );
@@ -651,7 +652,21 @@ class orderBookingRoute
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
{ {
if ($this->isOrderBookingCustomerSession() && $this->isOwnCustomerContext($targetCustomerNumber)) { $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; return;
} }
+55 -13
View File
@@ -75,13 +75,12 @@ class orderItemsRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 404); $response->error('Order not found', 404);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$product = (new products_o())->getProductById((int)$data['product_id']); $product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) { if (!$product->exists()) {
$response->error('Product not found', 404); $response->error('Product not found', 404);
} }
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
$customerRuleViolation = (new customer_product_rule_service()) $customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id); ->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) { if ($customerRuleViolation !== null) {
@@ -95,6 +94,9 @@ class orderItemsRoute
); );
$response->error($customerRuleViolation['message'], 400); $response->error($customerRuleViolation['message'], 400);
} }
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible // 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()); $order_items = (new order_items_o());
@@ -173,18 +175,38 @@ class orderItemsRoute
$this->delete('/order/items', function () { $this->delete('/order/items', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response, $db;
$this->requirePermission('delete_order_items'); $this->requirePermission('delete_order_items');
// Get the user object // Get the user object
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
// Check if the request was successful // Check if the request was successful
if ($user) { if ($user) {
// Get the query data // Get the order item id from the query string or request body
$data = $_GET; $itemIdRaw = $this->fromRequest('id');
// Check if the required fields are set if ($itemIdRaw === null || $itemIdRaw === '') {
if (!isset($data['id'])) {
$response->error('Order Item ID is required', 400); $response->error('Order Item ID is required', 400);
} }
$data = ['id' => $itemIdRaw];
// Look up the order item to check department access
$itemId = (int)$data['id'];
$stmt = $db->prepare('SELECT oi.order_id FROM order_items oi WHERE oi.id = ? LIMIT 1');
if ($stmt === false) {
(new logs_o())->add('order_items', 'global', 1, 0, 'DELETE_ORDER_ITEMS', 'Database error while preparing department access check query');
$response->error('Database error while checking department access', 500);
}
$stmt->bind_param('i', $itemId);
$stmt->execute();
$orderItemRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($orderItemRow !== null) {
$orderForAccess = (new orders_o())->getOrderById((int)$orderItemRow['order_id']);
if (!$orderForAccess->exists()) {
$response->error('Order not found', 404);
}
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
} else {
$response->error('Order item not found', 404);
}
// Delete the order item // Delete the order item
(new order_items_o())->removeOrderItem((int)$data['id']); (new order_items_o())->removeOrderItem((int)$data['id']);
// Return the list of departments // Return the list of departments
@@ -205,7 +227,7 @@ class orderItemsRoute
$this->put('/order/items', function () { $this->put('/order/items', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response, $db;
$this->requirePermission('edit_order_items'); $this->requirePermission('edit_order_items');
// Get the user object // Get the user object
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
@@ -230,19 +252,39 @@ class orderItemsRoute
$response->error('Quantity is required', 400); $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()) { if (!$orderItem->exists()) {
$response->error('Order item not found', 404); $response->error('Order item not found', 404);
} }
$product = (new products_o())->getProductById((int)$orderItem->product_id->value()); $orderItemContextResult = $db->query(
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') { "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); $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()) { if (!$order->exists()) {
$response->error('Order not found', 404); $response->error('Order not found', 404);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$canAccessAllOrderItems = $this->hasPermission('list_order_items'); $canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) { if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400); $response->error('Department not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$data['department_id']);
// Make sure the customer number set is valid // Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']); $targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) { if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $response->error('Order not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Get the base64 file // Get the base64 file
$base64_file = (string)$this->getParameter('base64_file'); $base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store(); $attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $response->error('Order not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Delete the attachment // Delete the attachment
$order->removeAttachment((int)$attachment_id); $order->removeAttachment((int)$attachment_id);
// Log the incident // Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $response->error('Order not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Mark the order as completed // Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value()); $order->markAsCompleted((string)$user->display_name->value());
// Log the incident // Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
} }
// Admin/department path (requires edit_order) // Admin/department path (requires edit_order)
self::requirePermission($permission_other); self::requirePermission($permission_other);
/** Departmental access */ /** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value(); $originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber; $newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false; $shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400); $response->error('Department not found', 400);
} }
// Check if the user has access to the target department
self::requireDepartmentAccess((string)(int)$data['department_id']);
$order->department_id->set((int)$data['department_id']); $order->department_id->set((int)$data['department_id']);
} }
// If the booking ID is set, validate it // If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{ {
try { try {
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) { return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
} catch (\Throwable) { } catch (\Throwable) {
return false; return false;
} }
+66 -33
View File
@@ -22,11 +22,13 @@ class productsRoute
*/ */
private function getCustomerIfProvided(): ?users_o private function getCustomerIfProvided(): ?users_o
{ {
global $response; $customerId = $this->getOptionalPositiveIntParameter('customer_id');
if (self::isParametersSet(['customer_id'])) { if ($customerId === null) {
$customerId = (int)self::getParameter('customer_id'); return null;
}
try { try {
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId); $customerObject = (new users_o())->getUserByCustomerNumber($customerId);
if ($customerObject->exists()) { if ($customerObject->exists()) {
return $customerObject; return $customerObject;
} }
@@ -36,7 +38,7 @@ class productsRoute
// Return null // Return null
return null; return null;
} }
}
return null; return null;
} }
@@ -46,13 +48,50 @@ class productsRoute
*/ */
private function getDepartmentIdIfProvided(): ?int private function getDepartmentIdIfProvided(): ?int
{ {
global $response; return $this->getOptionalPositiveIntParameter('department_id');
if (self::isParametersSet(['department_id'])) {
return (int)self::getParameter('department_id');
} }
private function getOptionalPositiveIntParameter(string $parameter): ?int
{
global $response;
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 private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
{ {
if (!$user instanceof users_o || $departmentId === null) { if (!$user instanceof users_o || $departmentId === null) {
@@ -72,11 +111,7 @@ class productsRoute
*/ */
private function getCategoryIfProvided(): ?int private function getCategoryIfProvided(): ?int
{ {
global $response; return $this->getOptionalPositiveIntParameter('category');
if (self::isParametersSet(['category'])) {
return (int)self::getParameter('category');
}
return null;
} }
/** /**
@@ -85,11 +120,7 @@ class productsRoute
*/ */
private function getProductIdIfProvided(): ?int private function getProductIdIfProvided(): ?int
{ {
global $response; return $this->getOptionalPositiveIntParameter('id');
if (self::isParametersSet(['id'])) {
return (int)self::getParameter('id');
}
return null;
} }
/** /**
@@ -210,13 +241,14 @@ class productsRoute
// Check if the request was successful // Check if the request was successful
if ($user || $isProductDetailsRestricted) { if ($user || $isProductDetailsRestricted) {
// Define the variables // Define the variables
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided $customer = $this->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 $departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$this->assertCanUseDepartmentPricing($user, $departmentId); $category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$category = self::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)
$productId = self::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. // 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 // Determine the products to return
if ($category) { if ($category) {
// Get products in the category // Get products in the category
@@ -274,17 +306,17 @@ class productsRoute
); );
} }
// Check if the category is set in the request // Check if the category is set in the request
$data = $_GET ?? [];
// Check if the category is set // Check if the category is set
if (isset($data['category'])) { if ($category !== null) {
// Log the incident // 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 // 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 // Check if the department_id is set
if (isset($data['department_id'])) { if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Apply the departments unique pricing // 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( $response->success(
array_map(function ($product) use ($isProductDetailsRestricted) { array_map(function ($product) use ($isProductDetailsRestricted) {
@@ -295,9 +327,10 @@ class productsRoute
// Log the incident // Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products'); (new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set // 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 // 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 // Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) { $departmentSpecificProductIds = array_map(function ($product) {
return $product->id; return $product->id;
@@ -312,7 +345,7 @@ class productsRoute
(new products_o())->forceRestrictFilters([ (new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds, 'id' => $departmentSpecificProductIds,
]) ])
), (int)$data['department_id']) ), $departmentId)
); );
} }
// Return the list of products // Return the list of products
+20
View File
@@ -3,6 +3,7 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o; use objects\groups_o;
use objects\logs_o; use objects\logs_o;
use traits\route_t; use traits\route_t;
@@ -106,6 +107,25 @@ class rolesRoute
] ]
); );
self::get('/roles/limited-backoffice-permission-templates', function () {
global $response;
self::requirePermission('superuser');
self::requirePermission('add_role_permission');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
} else {
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
$response->error('Invalid session', 400);
}
},
[
'superuser' => 'Access the superuser interface',
'add_role_permission' => 'Add a permission to a role'
]
);
self::post('/roles/permissions', function () { self::post('/roles/permissions', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
+30 -2
View File
@@ -103,6 +103,9 @@ class userRoute
} }
// Check if the required fields are set // Check if the required fields are set
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$response->error('Invalid request body', 400);
}
if (!isset($data['discount'])) { if (!isset($data['discount'])) {
// Log the incident // Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set'); (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); $response->error('No is_category set', 400);
} }
$discount = (int)$data['discount']; $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']; $is_category = (bool)$data['is_category'];
if ($is_category) { if ($is_category) {
$object_id = (string)$data['object_id']; $object_id = (string)$data['object_id'];
} else { } else {
$object_id = (int)$data['object_id']; $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 // 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 { try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion( (new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$targetUser->id, (int)$targetUser->id,
@@ -145,7 +172,8 @@ class userRoute
'route' => '/superuser/user/discounts', 'route' => '/superuser/user/discounts',
'method' => 'POST', 'method' => 'POST',
'actor_user_id' => (int)$user->id, 'actor_user_id' => (int)$user->id,
] ],
$fixed_price
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
(new logs_o())->add( (new logs_o())->add(
+86 -5
View File
@@ -27,9 +27,8 @@ class usersRoute
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users'); (new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
// Return the list of users // Return the list of users
$users_o = new users_o(); $users_o = new users_o();
$response->success( $limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
$users_o->parseUsers( $users = $users_o
$users_o
->setSearchableFields([ ->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 // 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', 'id',
@@ -37,9 +36,19 @@ class usersRoute
'group_id', 'group_id',
'display_name', 'display_name',
]) ])
->listObjectsWithPaginationIfSet() ->listObjectsWithPaginationIfSet(
) null,
$limitedEmployeeListMode['filters'],
[],
$limitedEmployeeListMode['additional_where']
); );
if ($limitedEmployeeListMode['enabled']) {
$users = $users_o->markLimitedBackofficeManagedUsers($users);
}
$users = $users_o->parseUsers(
$users
);
$response->success($users);
} else { } else {
// Log the incident // Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session'); (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'] === '') { if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
$data['display_name'] = null; $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 the role is set, require the edit_user_role permission
if ($data['role']) { if ($data['role']) {
$this->requirePermission('edit_user_role'); $this->requirePermission('edit_user_role');
@@ -207,4 +233,59 @@ class usersRoute
] ]
); );
} }
/**
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
*/
private function limitedBackofficeEmployeeListMode(users_o $users): array
{
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if ($filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
'additional_where' => null,
];
}
$filterArray = $users->filter_string_to_array($filters);
$customerNumberFilter = $filterArray['customer_number'] ?? null;
$isEmployeeFilter = $customerNumberFilter === '0'
|| $customerNumberFilter === 0
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) {
// When include mode is on but the filter is not a customer_number:0 query,
// pass the original filter through as forced filters so they are not discarded.
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
// back to reading the filters from the request, which is equivalent.
return [
'enabled' => false,
'filters' => $enabled ? $filters : null,
'additional_where' => null,
];
}
// $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
if (!$enabled) {
// Exclude active limited backoffice employees when the include flag is not set.
return [
'enabled' => false,
'filters' => null,
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
];
}
unset($filterArray['customer_number']);
return [
'enabled' => true,
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
];
}
} }
@@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` (
`is_category` TINYINT(1) NOT NULL, `is_category` TINYINT(1) NOT NULL,
`object_id` VARCHAR(64) NOT NULL, `object_id` VARCHAR(64) NOT NULL,
`discount` INT NOT NULL, `discount` INT NOT NULL,
`fixed_price` INT NULL DEFAULT NULL,
`effective_from` DATETIME NOT NULL, `effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL, `effective_to` DATETIME NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test', `source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
@@ -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');
});
File diff suppressed because it is too large Load Diff
@@ -65,7 +65,7 @@ it('lets customers create their own order bookings without booking permissions',
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
}); });
it('lets subusers create own customer order bookings without the bookings add node', function (): void { it('blocks subusers creating own customer order bookings without the bookings add node', function (): void {
api_test_covers('POST /order-bookings', 'auth'); api_test_covers('POST /order-bookings', 'auth');
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']); $customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']);
@@ -79,6 +79,27 @@ it('lets subusers create own customer order bookings without the bookings add no
$session['headers'] $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 $response
->assertStatus(200) ->assertStatus(200)
->assertEnvelope() ->assertEnvelope()
@@ -115,6 +115,67 @@ it('requires notes when adding the extraordinary chemistry product to an order',
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side'); expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
}); });
it('uses a product fixed price instead of the best discount when adding an order item', function (): void {
api_test_covers('POST /order/items', 'pricing');
api_test_covers('GET /products', 'pricing');
$customer = api_fixtures()->createUser(['display_name' => 'Fixed Price Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Fixed Price Cashier']);
$category = api_fixtures()->createCategory(['name' => 'Fixed Price Category']);
$product = api_fixtures()->createProduct([
'name' => 'Fixed Price Product',
'price' => 1000,
'category' => $category['id'],
'apply_category_discount' => 1,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 1,
'product_or_category_id' => (string)$category['id'],
'percentage' => 80,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 0,
'product_or_category_id' => (string)$product['id'],
'percentage' => 10,
'fixed_price' => 350,
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'FIXED-PRICE',
]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products', 'department_access_' . $department['id']]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->data()['price'] ?? null)->toBe(350);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['price'] ?? null)->toBe(350);
});
it('only allows tankcleaning products for only tankcleaning customers', function (): void { it('only allows tankcleaning products for only tankcleaning customers', function (): void {
api_test_covers('POST /order/items', 'customer_rules'); api_test_covers('POST /order/items', 'customer_rules');
@@ -207,7 +268,8 @@ it('does not allow clearing notes for order items whose product requires notes',
'reference' => 'NOTE-EDIT', 'reference' => 'NOTE-EDIT',
]); ]);
$product = api_fixtures()->createProduct([ $product = api_fixtures()->createProduct([
'name' => 'API Note Required Product', 'id' => 902702,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 199, 'price' => 199,
'requires_note' => 1, 'requires_note' => 1,
]); ]);
@@ -219,7 +281,7 @@ it('does not allow clearing notes for order items whose product requires notes',
'quantity' => 1, 'quantity' => 1,
'notes' => 'Initial note', 'notes' => 'Initial note',
]); ]);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']); $session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client() api_client()
->put('/order/items', [ ->put('/order/items', [
@@ -73,7 +73,7 @@ it('creates orders through the orders endpoint', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']); $customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Order Create Department']); $department = api_fixtures()->createDepartment(['name' => 'Order Create Department']);
$session = api_fixtures()->createUserSession(['add_order']); $session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
$response = api_client()->post('/orders', [ $response = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'], 'customer_id' => $customer['customer_number'],
@@ -128,7 +128,7 @@ it('defaults order PO only from a matching active booking', function (): void {
'po' => 'DELETED-BOOKING-PO', 'po' => 'DELETED-BOOKING-PO',
'deleted_at' => date('Y-m-d H:i:s'), 'deleted_at' => date('Y-m-d H:i:s'),
]); ]);
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [ $session = api_fixtures()->createUserSession(['add_order', 'edit_order', 'department_access_' . $department['id']], [
'customer_number' => $customer['customer_number'], 'customer_number' => $customer['customer_number'],
]); ]);
@@ -149,7 +149,7 @@ it('defaults order PO only from a matching active booking', function (): void {
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0); $matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO'); expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [ $unauthorizedSession = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']], [
'customer_number' => $otherCustomer['customer_number'], 'customer_number' => $otherCustomer['customer_number'],
]); ]);
$unauthorizedResponse = api_client()->post('/orders', [ $unauthorizedResponse = api_client()->post('/orders', [
@@ -233,7 +233,7 @@ it('rejects invalid order creation requests', function (): void {
$customer = api_fixtures()->createUser(); $customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment(); $department = api_fixtures()->createDepartment();
$session = api_fixtures()->createUserSession(['add_order']); $session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
api_client()->post('/orders', [ api_client()->post('/orders', [
'customer_id' => $customer['customer_number'], 'customer_id' => $customer['customer_number'],
@@ -262,7 +262,7 @@ it('updates orders through the primary and legacy endpoints', function (): void
'notes' => 'Before update', 'notes' => 'Before update',
'reg_1' => 'BEFORE1', 'reg_1' => 'BEFORE1',
]); ]);
$session = api_fixtures()->createUserSession(['edit_order']); $session = api_fixtures()->createUserSession(['edit_order', 'department_access_' . $department['id']]);
api_client()->put('/orders', [ api_client()->put('/orders', [
'id' => $order['id'], 'id' => $order['id'],
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('treats null-like optional product params as omitted for product detail requests', function (): void {
api_test_covers('GET /products', 'optional-params');
$product = api_fixtures()->createProduct([
'name' => 'Null Query Product',
'price' => 400,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->get(
'/products?id=' . (int)$product['id']
. '&department_id=null&customer_id=null&category_id=null&final_price=false',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id']);
expect($response->body)->not->toContain('department_access_0');
});
it('still requires department access when final product pricing uses a real department', function (): void {
api_test_covers('GET /products', 'permissions');
$department = api_fixtures()->createDepartment(['name' => 'Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Department Priced Product',
'price' => 500,
]);
$session = api_fixtures()->createUserSession(['list_products']);
api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
it('rejects invalid department ids without requesting department access zero', function (): void {
api_test_covers('GET /products', 'validation');
$product = api_fixtures()->createProduct([
'name' => 'Invalid Department Product',
'price' => 600,
]);
$session = api_fixtures()->createUserSession(['list_products']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid department_id');
expect($response->body)->not->toContain('department_access_0');
});
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lists limited backoffice permission templates for superuser role maintenance', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'happy');
$session = api_fixtures()->createUserSession([
'superuser',
'add_role_permission',
]);
$response = api_client()->get('/roles/limited-backoffice-permission-templates', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$templates = $response->data();
expect(array_column($templates, 'key'))->toBe([
'viewer',
'cashier',
'booking_coordinator',
'operations_lead',
'department_admin',
]);
$templatesByKey = array_column($templates, null, 'key');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_department_daily_reports');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_notifications');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('statistics_orders_new');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_access');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_prices_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_employees_manage');
});
it('requires superuser and role permission edit access for limited backoffice permission templates', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'auth');
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['add_role_permission'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser']);
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['superuser'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_role_permission']);
});
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('sets preserves and clears product fixed prices through the user discounts endpoint', function (): void {
api_test_covers('POST /superuser/user/discounts', 'pricing');
api_test_covers('GET /superuser/user/discounts', 'pricing');
$customer = api_fixtures()->createUser(['display_name' => 'Endpoint Fixed Price Customer']);
$product = api_fixtures()->createProduct([
'name' => 'Endpoint Fixed Price Product',
'price' => 900,
]);
$session = api_fixtures()->createUserSession([
'set_custom_price',
'get_custom_prices_other',
]);
$findProductRow = function () use ($customer, $product, $session): array {
$response = api_client()->get(
'/superuser/user/discounts?user_id=' . $customer['id'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
foreach ($response->data() as $row) {
if ((int)($row['product_or_category_id'] ?? 0) === (int)$product['id'] && !($row['is_category'] ?? false)) {
return $row;
}
}
throw new RuntimeException('Expected product override row was not returned.');
};
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 20,
'fixed_price' => 350,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(20);
expect((int)$row['fixed_price'])->toBe(350);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(10);
expect((int)$row['fixed_price'])->toBe(350);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
'fixed_price' => null,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(10);
expect($row['fixed_price'])->toBeNull();
});
it('rejects invalid fixed price payloads for user discounts', function (): void {
api_test_covers('POST /superuser/user/discounts', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Invalid Fixed Price Customer']);
$product = api_fixtures()->createProduct(['name' => 'Invalid Fixed Price Product']);
$category = api_fixtures()->createCategory(['name' => 'Invalid Fixed Price Category']);
$session = api_fixtures()->createUserSession(['set_custom_price']);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
'fixed_price' => '12.5',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => (string)$category['id'],
'is_category' => true,
'discount' => 10,
'fixed_price' => 350,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -71,6 +71,7 @@ final class ApiFixtures
$this->deleteRedisKey('`users`_' . $customerNumber . '_economic_customer_name'); $this->deleteRedisKey('`users`_' . $customerNumber . '_economic_customer_name');
$this->deleteRedisKey('users_' . $userId . '_economic_customer'); $this->deleteRedisKey('users_' . $userId . '_economic_customer');
$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('perm:user:' . $userId . ':*');
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*'); $this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
}); });
@@ -78,6 +79,7 @@ final class ApiFixtures
$economicName = (string)($attributes['economic_customer_name'] ?? $displayName); $economicName = (string)($attributes['economic_customer_name'] ?? $displayName);
$this->seedCustomerNameCache($customerNumber, $economicName); $this->seedCustomerNameCache($customerNumber, $economicName);
$this->seedEconomicCustomerCache($userId, $customerNumber, $economicName, $email); $this->seedEconomicCustomerCache($userId, $customerNumber, $economicName, $email);
$this->seedEconomicCustomerDiscountCache($userId, (int)($attributes['economic_customer_discount_percentage'] ?? 0));
return [ return [
'id' => $userId, 'id' => $userId,
@@ -661,6 +663,33 @@ final class ApiFixtures
return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []); 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 public function linkDepartmentCategory(int $departmentId, int $categoryId): int
{ {
$linkId = $this->insertRow('department_categories', [ $linkId = $this->insertRow('department_categories', [
@@ -1805,6 +1834,11 @@ final class ApiFixtures
$this->setRedisJson('`users`_' . $userId . '_economic_customer', $payload); $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 * @param array<string, mixed> $data
*/ */
@@ -2176,6 +2210,16 @@ final class ApiFixtures
$this->cleanup->add(fn() => $this->deleteRedisKey($key)); $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 private function deleteRedisKey(string $key): void
{ {
if ($this->redis === null) { if ($this->redis === null) {
@@ -21,6 +21,7 @@ final class ApiSchemaBootstrap
$this->ensureDepartmentArchiveSchema(); $this->ensureDepartmentArchiveSchema();
$this->ensureOrderInvoiceCollectionSchema(); $this->ensureOrderInvoiceCollectionSchema();
$this->ensurePriceOverrideSchema();
foreach ($this->viewStatements() as $name => $sql) { foreach ($this->viewStatements() as $name => $sql) {
$this->execute($name, $sql); $this->execute($name, $sql);
@@ -773,6 +774,7 @@ CREATE TABLE IF NOT EXISTS `price_overrides` (
`is_category` TINYINT(1) NOT NULL DEFAULT 0, `is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL, `product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0, `percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@@ -980,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 private function columnExists(string $table, string $column): bool
{ {
$table = $this->db->real_escape_string($table); $table = $this->db->real_escape_string($table);
@@ -62,6 +62,7 @@ it('returns the raw upstream create response and preserves the requested payload
expect($probe->inner->lastPayload['phone'])->toBe(42331123); expect($probe->inner->lastPayload['phone'])->toBe(42331123);
expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123'); expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123');
expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123'); expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123');
expect(array_key_exists('ean', $probe->inner->lastPayload))->toBeFalse();
}); });
it('adds supported CVR company fields to the e-conomic customer payload', function (): void { it('adds supported CVR company fields to the e-conomic customer payload', function (): void {
@@ -97,3 +98,46 @@ it('adds supported CVR company fields to the e-conomic customer payload', functi
expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788'); expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788');
expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse(); expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse();
}); });
it('adds a normalized EAN to the e-conomic customer payload when provided', function (): void {
$stubResponse = (object)[
'customerNumber' => 42331123,
'name' => 'Truckwash ApS',
];
$probe = new EconomicCreateCustomerProbe($stubResponse);
$probe->createCustomer(
42331123,
'Truckwash ApS',
37781258,
'invoice@truckwash.test',
42331123,
null,
null,
'57 90-001234567',
);
expect($probe->inner->lastPayload['ean'])->toBe('5790001234567');
});
it('rejects EAN values longer than e-conomic accepts', function (): void {
$stubResponse = (object)[
'customerNumber' => 42331123,
'name' => 'Truckwash ApS',
];
$probe = new EconomicCreateCustomerProbe($stubResponse);
$call = static fn() => $probe->createCustomer(
42331123,
'Truckwash ApS',
37781258,
'invoice@truckwash.test',
42331123,
null,
null,
'57900012345678',
);
expect($call)->toThrow(InvalidArgumentException::class, 'EAN must be at most 13 digits.');
expect($probe->inner->lastPayload)->toBe([]);
});
@@ -0,0 +1,12 @@
<?php
it('requires the BOOKINGS_ADD subuser node for own order booking creation', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
expect(is_file($routeFile))->toBeTrue();
$code = (string)file_get_contents($routeFile);
$normalized = preg_replace('/\s+/', ' ', $code);
expect($normalized)->toContain("definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD)");
expect($normalized)->toContain("'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'");
});
@@ -176,6 +176,22 @@ it('creates a new e-conomic customer and returns a created result for new rows',
expect($result['has_account'])->toBeFalse(); expect($result['has_account'])->toBeFalse();
}); });
it('rejects EAN values longer than e-conomic accepts before creating customers', function (): void {
$service = new CustomerMassImportServiceProbe();
$call = static fn() => $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'tgp@example.com',
'ean' => '57900012345678',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'EAN must be at most 13 digits.');
expect($service->createCalls)->toBe([]);
expect($service->bootstrapCalls)->toBe([]);
});
it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void { it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void {
$service = new CustomerMassImportServiceProbe(); $service = new CustomerMassImportServiceProbe();
$service->localExists = true; $service->localExists = true;
@@ -31,10 +31,15 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
$content = department_daily_reports_openapi_content_or_skip(); $content = department_daily_reports_openapi_content_or_skip();
expect($content)->toContain('/departments/daily-reports/overview:'); 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('/superuser/departments/{id}/overview:');
expect($content)->toContain('operationId: getDailyReportOverview'); expect($content)->toContain('operationId: getDailyReportOverview');
expect($content)->toContain('operationId: setDailyReportProductTarget');
expect($content)->toContain('operationId: getSuperuserDepartmentOverview'); expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
expect($content)->toContain('DepartmentDailyReportOverviewResponse:'); 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('SuperuserDepartmentOverviewResponse:');
expect($content)->toContain('DepartmentDailyReportMetric:'); expect($content)->toContain('DepartmentDailyReportMetric:');
expect($content)->toContain('DepartmentDailyReportProductTile:'); expect($content)->toContain('DepartmentDailyReportProductTile:');
@@ -123,6 +123,7 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
public object $complaints_repository; public object $complaints_repository;
public array $opening_hours = []; public array $opening_hours = [];
public array $departments = []; public array $departments = [];
public array $product_targets_by_department = [];
public array $workfeed_departments = []; public array $workfeed_departments = [];
public array $workfeed_shifts = []; public array $workfeed_shifts = [];
public department_outside_hours_statistics_service $outside_hours_service; public department_outside_hours_statistics_service $outside_hours_service;
@@ -161,6 +162,11 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
{ {
return $this->outside_hours_service; 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 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]['slug'])->toBe('spot-free-lastbil');
expect($overview['products'][0]['title'])->toBe('Spot Free (Lastbil)'); expect($overview['products'][0]['title'])->toBe('Spot Free (Lastbil)');
expect($overview['products'][0]['value'])->toBe(3); 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]['title'])->toBe('Fælg flex pr. enhed');
expect($overview['products'][1]['value'])->toBe(2); expect($overview['products'][1]['value'])->toBe(2);
expect(array_column($overview['products'], 'title'))->toBe([ 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 { it('marks overtime unavailable when not every selected department can be mapped to workfeed', function (): void {
$repository = new FakeDailyReportRepository(); $repository = new FakeDailyReportRepository();
@@ -342,8 +382,10 @@ it('wires the overview route to batched repository methods and overview path', f
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php')); $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/overview');
expect($routeContent)->toContain('/departments/daily-reports/product-targets');
expect($routeContent)->toContain('/superuser/departments/{id}/overview'); expect($routeContent)->toContain('/superuser/departments/{id}/overview');
expect($routeContent)->toContain('superuser_fetch_department'); 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('/departments/daily-reports/complaints');
expect($routeContent)->toContain('outsideHoursStatisticsService'); expect($routeContent)->toContain('outsideHoursStatisticsService');
expect($routeContent)->toContain('dailyReportComplaintsRepository'); expect($routeContent)->toContain('dailyReportComplaintsRepository');
@@ -0,0 +1,40 @@
<?php
app_require('modules/economic/helpers/economic_customer.php');
use helpers\economic_customer;
function economic_customer_helper_from_payload(object $payload): economic_customer
{
$reflection = new ReflectionClass(economic_customer::class);
/** @var economic_customer $customer */
$customer = $reflection->newInstanceWithoutConstructor();
$property = $reflection->getProperty('customer_data_object');
$property->setAccessible(true);
$property->setValue($customer, $payload);
return $customer;
}
it('exposes optional EAN and public entry number from fetched e-conomic customer data', function (): void {
$customer = economic_customer_helper_from_payload((object)[
'customerNumber' => 42331123,
'ean' => ' 5790001234567 ',
'publicEntryNumber' => ' DK123456789 ',
]);
expect($customer->getEan())->toBe('5790001234567');
expect($customer->getPublicEntryNumber())->toBe('DK123456789');
});
it('returns null for blank optional e-conomic customer recipient identifiers', function (): void {
$customer = economic_customer_helper_from_payload((object)[
'customerNumber' => 42331123,
'ean' => ' ',
'publicEntryNumber' => '',
]);
expect($customer->getEan())->toBeNull();
expect($customer->getPublicEntryNumber())->toBeNull();
});
@@ -0,0 +1,50 @@
<?php
function economic_ean_openapi_content_or_skip(): string
{
$candidates = [];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
foreach (array_values(array_unique($candidates)) as $candidate) {
if (is_file($candidate)) {
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
function economic_ean_openapi_block(string $content, string $start, string $end): string
{
$start_pos = strpos($content, $start);
$end_pos = strpos($content, $end);
expect($start_pos)->not->toBeFalse();
expect($end_pos)->not->toBeFalse();
expect($end_pos)->toBeGreaterThan($start_pos);
return substr($content, (int)$start_pos, (int)$end_pos - (int)$start_pos);
}
it('documents optional EAN on customer creation endpoints', function (): void {
$content = economic_ean_openapi_content_or_skip();
$register_block = economic_ean_openapi_block($content, '/auth/register/cvr:', '/auth/password-reset/request:');
$economic_customer_block = economic_ean_openapi_block($content, '/modules/economic/customer:', '/economic/layouts:');
foreach ([$register_block, $economic_customer_block] as $block) {
expect($block)->toContain('ean:');
expect($block)->toContain('maxLength: 13');
expect($block)->toContain("pattern: '^[0-9]{1,13}$'");
}
});
@@ -0,0 +1,12 @@
<?php
it('wires EAN and public entry number into e-conomic invoice draft recipients', function (): void {
$content = file_get_contents(app_path('modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('$customer->getEan()');
expect($content)->toContain("\$recipient['ean']");
expect($content)->toContain('$customer->getPublicEntryNumber()');
expect($content)->toContain("\$recipient['publicEntryNumber']");
expect($content)->toContain("'recipient' => \$recipient");
});
@@ -0,0 +1,167 @@
<?php
app_require('classes/economic_v2_versioning_service.php');
app_require('classes/economic_v2_distribution_service.php');
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
if (!class_exists('FakeEconomicV2ProductFixedPriceVersioningService')) {
class FakeEconomicV2ProductFixedPriceVersioningService extends economic_v2_versioning_service
{
public function __construct()
{
}
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
{
return null;
}
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
{
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
{
if (!$is_category && (int)$object_id === 42) {
return [
'customer_number' => $customer_number,
'is_category' => 0,
'object_id' => '42',
'discount' => 10,
'fixed_price' => 350,
];
}
if ($is_category) {
return [
'customer_number' => $customer_number,
'is_category' => 1,
'object_id' => (string)$object_id,
'discount' => 80,
'fixed_price' => null,
];
}
return null;
}
public function runBestEffortBackfill(): array
{
return [];
}
}
}
if (!class_exists('TestableEconomicV2ProductFixedPriceDistributionService')) {
class TestableEconomicV2ProductFixedPriceDistributionService extends economic_v2_distribution_service
{
public function __construct()
{
parent::__construct(new FakeEconomicV2ProductFixedPriceVersioningService());
}
public function exposeCalculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float
{
return $this->calculateOrderOriginalPrice($order_items, $customer_number, $department_id, $timestamp);
}
protected function ensureVersionHistoryAvailable(array $areas): void
{
}
protected function fetchOrdersInRange(string $from_ts, string $to_ts): array
{
return [[
'id' => 1001,
'customer_id' => 35131752,
'department_id' => 7,
'created_at' => '2026-01-05 12:00:00',
'include_in_invoice' => 1,
]];
}
protected function fetchOrderItemsByOrderIds(array $order_ids): array
{
return [
1001 => [[
'product_id' => 42,
'quantity' => 2,
'price' => 0,
]],
];
}
protected function getProductDepartmentPrice(int $product_id, int $department_id): float
{
return 1000.0;
}
protected function isOrderEligible(array $order): bool
{
return true;
}
protected function shouldIncludeCustomerNumber(int $customer_number): bool
{
return $customer_number > 0;
}
protected function parseDepartmentMap(array $department_map): array
{
$parsed = [];
foreach ($department_map as $department_id => $amount) {
$parsed['Department ' . $department_id] = round((float)$amount, 5);
}
return $parsed;
}
protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array
{
return [
'id' => $customer_number,
'customer_number' => $customer_number,
'customer_name' => 'Customer ' . $customer_number,
'transactions' => array_values($transaction_map),
'requires_action' => false,
'meta' => [],
];
}
protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array
{
return [
'id' => $order_id,
'date' => $created_at,
'amount' => round((float)($amount ?? 0.0), 5),
'booked' => true,
'department_id' => $department_id,
'excluded' => !($included ?? true),
];
}
}
}
it('uses product fixed prices before discounts in customer price distributions', function (): void {
$service = new TestableEconomicV2ProductFixedPriceDistributionService();
$result = $service->getCustomerPricesDistribution('2026-01-01', '2026-01-31');
expect($result['collective_results']['total_discount_amount'])->toBe(1300.0);
expect($result['collective_results']['department_discount_totals'][7])->toBe(1300.0);
expect($result['customers'][0]['meta']['customer_prices']['discount_total'])->toBe(1300.0);
expect($result['customers'][0]['transactions'][0]['amount'])->toBe(1300.0);
expect($service->exposeCalculateOrderOriginalPrice(
[[
'product_id' => 42,
'quantity' => 2,
'price' => 0,
]],
35131752,
7,
'2026-01-05 12:00:00'
))->toBe(700.0);
});
@@ -579,6 +579,33 @@ it('uses the highest customer-specific discount in expected price breakdowns', f
]); ]);
}); });
it('uses a product fixed price before customer discounts in expected price breakdowns', function (): void {
$row = [
'customer_number' => 0,
'product_base_price' => 1000,
'department_price' => null,
'product_fixed_price' => 350,
'product_discount_percentage' => 10,
'category_discount_percentage' => 80,
'apply_category_discount' => 1,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
expect($expected)->toBe(350);
expect($breakdown)->toMatchArray([
'product_price' => 1000,
'effective_base_price' => 1000,
'product_fixed_price' => 350,
'product_discount_percentage' => 10,
'category_discount_percentage' => 80,
'economic_customer_discount_percentage' => 0,
'applied_discount_percentage' => 0,
'expected_price' => 350,
]);
});
it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void { it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void {
$service = invoice_period_flag_service_instance(); $service = invoice_period_flag_service_instance();
$reflection = new ReflectionClass(invoice_period_flag_service::class); $reflection = new ReflectionClass(invoice_period_flag_service::class);
@@ -18,6 +18,8 @@ it('parses cached economic customer payloads that use snake_case customer_number
'customer_number' => '42331123', 'customer_number' => '42331123',
'name' => 'Truckwash ApS', 'name' => 'Truckwash ApS',
'email' => 'jb@truckwash.dk', 'email' => 'jb@truckwash.dk',
'ean' => '5790001234567',
'public_entry_number' => 'DK123456789',
'currency' => 'DKK', 'currency' => 'DKK',
'country' => 'DK', 'country' => 'DK',
'barred' => true, 'barred' => true,
@@ -31,6 +33,8 @@ it('parses cached economic customer payloads that use snake_case customer_number
'zip' => null, 'zip' => null,
'corporateIdentificationNumber' => null, 'corporateIdentificationNumber' => null,
'email' => 'jb@truckwash.dk', 'email' => 'jb@truckwash.dk',
'ean' => '5790001234567',
'publicEntryNumber' => 'DK123456789',
'mobilePhone' => null, 'mobilePhone' => null,
'currency' => 'DKK', 'currency' => 'DKK',
'country' => 'DK', 'country' => 'DK',
@@ -3,6 +3,20 @@
use helpers\xlvask_usage_log; use helpers\xlvask_usage_log;
use objects\xlvask_usage_logs_o; 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 { it('accepts persisted ignore metadata from xlvask usage log rows', function (): void {
$log = new xlvask_usage_log(); $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', '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_primary_product_name'] = \$amount_summary['primary_product_name']")
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
}); });
it('scopes manual XL Vask usage import and automation to optional period dates', function (): void {
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($route)
->not->toBeFalse()
->and($automation)->not->toBeFalse();
$route = (string)$route;
$automation = (string)$automation;
expect($route)
->toContain("getParameter('dateFrom')")
->toContain("getParameter('dateTo')")
->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)')
->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
});
@@ -105,7 +105,30 @@ namespace classes {
}; };
} }
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null): object public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null, $ean = null): object
{ {
self::$create_calls[] = [ self::$create_calls[] = [
'number' => (int)$number, 'number' => (int)$number,
@@ -115,6 +138,7 @@ namespace classes {
'phone' => (int)$phone, 'phone' => (int)$phone,
'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone, 'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone,
'company_information' => $companyInformation, 'company_information' => $companyInformation,
'ean' => $ean === null ? null : (string)$ean,
]; ];
if (self::$mock_create_exception !== null) { if (self::$mock_create_exception !== null) {
@@ -424,6 +448,16 @@ namespace {
'expected_error' => 'Parameter cvr must be at least 8 characters long', 'expected_error' => 'Parameter cvr must be at least 8 characters long',
'expected_status' => 400, 'expected_status' => 400,
], ],
[
'name' => 'Invalid EAN length (too long)',
'params' => array_merge($baseParams, ['ean' => '57900012345678']),
'expected_error' => 'EAN must be at most 13 digits.',
'expected_status' => 400,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 0, 'Invalid EAN must not create e-conomic customers.');
assert_true(count(\classes\email::$sent) === 0, 'Invalid EAN must not send welcome emails.');
},
],
[ [
'name' => 'CVR lookup failure returns validation error without creating customer', 'name' => 'CVR lookup failure returns validation error without creating customer',
'params' => array_merge($baseParams, ['cvr' => '11111112']), 'params' => array_merge($baseParams, ['cvr' => '11111112']),
@@ -563,7 +597,7 @@ namespace {
], ],
[ [
'name' => 'Successful registration bootstraps local user before welcome emails', 'name' => 'Successful registration bootstraps local user before welcome emails',
'params' => array_merge($baseParams, ['contactPhone' => 87654320]), 'params' => array_merge($baseParams, ['contactPhone' => 87654320, 'ean' => '57 90-001234567']),
'setup' => static function (): void { 'setup' => static function (): void {
\classes\economic::$mock_create_response = (object)[ \classes\economic::$mock_create_response = (object)[
'customerNumber' => 12345678, 'customerNumber' => 12345678,
@@ -579,6 +613,7 @@ namespace {
assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.'); assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.');
assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.'); assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.');
assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.'); assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.');
assert_true(\classes\economic::$create_calls[0]['ean'] === '5790001234567', 'Fresh registration must pass normalized EAN to e-conomic.');
$companyInformation = \classes\economic::$create_calls[0]['company_information']; $companyInformation = \classes\economic::$create_calls[0]['company_information'];
assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.'); assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.');
assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.'); assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.');