Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce43c4e064 | ||
|
|
579ddcf510 | ||
|
|
0b342a7780 | ||
|
|
57bcbaf72a | ||
|
|
d9fbba3130 | ||
|
|
e4465d9d91 | ||
|
|
734cd13c87 | ||
|
|
d0f94ac549 | ||
|
|
1d25cbe21c | ||
|
|
53d0636193 | ||
|
|
04bb26f1b0 | ||
|
|
df0d4783d0 | ||
|
|
39c06ceab6 | ||
|
|
7dd428d18e | ||
|
|
0103a40156 | ||
|
|
e208b1b2a4 | ||
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
ed2736e528 | ||
|
|
c7f5c73a9e | ||
|
|
c10af48954 | ||
|
|
7ac5c5585b | ||
|
|
8544ce0a18 | ||
|
|
614715822f | ||
|
|
1da02e2486 | ||
|
|
742b15116d | ||
|
|
e0ae74bdc2 | ||
|
|
08dc803b3e | ||
|
|
248a901f24 | ||
|
|
8bbdf9daf5 | ||
|
|
c089186046 | ||
|
|
d9eacf6f84 | ||
|
|
f02dfd8c9c | ||
|
|
db1b9a2c96 |
+120
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+19
-10
@@ -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
|
||||||
@@ -194,4 +203,4 @@ class economic_invoices_drafts_endpoint
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -227,4 +250,4 @@ class economic_customer
|
|||||||
return $this->customer_data_object->vatZone->vatZoneNumber;
|
return $this->customer_data_object->vatZone->vatZoneNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
$db->query($sql);
|
||||||
|
$this->markSearchDirty();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
|
||||||
|
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false);
|
||||||
|
}
|
||||||
|
$discount_cache[$product_id] = $unit_price;
|
||||||
}
|
}
|
||||||
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
|
$post_discount = (int)$discount_cache[$product_id] * $quantity;
|
||||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
|
|
||||||
$unit_price = (int)round($unit_price * (1 - ($discount_cache[$product_id] / 100)));
|
|
||||||
}
|
|
||||||
$post_discount = $unit_price * $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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -76,4 +87,4 @@ class moduleEconomicCustomerRoute
|
|||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,23 @@ 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 {
|
|
||||||
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
|
|
||||||
if ($customerObject->exists()) {
|
|
||||||
return $customerObject;
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Log the incident
|
|
||||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
|
||||||
// Return null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
|
||||||
|
if ($customerObject->exists()) {
|
||||||
|
return $customerObject;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Log the incident
|
||||||
|
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||||
|
// Return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +47,49 @@ class productsRoute
|
|||||||
* @return int|null
|
* @return int|null
|
||||||
*/
|
*/
|
||||||
private function getDepartmentIdIfProvided(): ?int
|
private function getDepartmentIdIfProvided(): ?int
|
||||||
|
{
|
||||||
|
return $this->getOptionalPositiveIntParameter('department_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getOptionalPositiveIntParameter(string $parameter): ?int
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
if (self::isParametersSet(['department_id'])) {
|
if (!self::isParametersSet([$parameter])) {
|
||||||
return (int)self::getParameter('department_id');
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -182,4 +202,4 @@ class rolesRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -27,19 +27,28 @@ 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',
|
'customer_number',
|
||||||
'customer_number',
|
'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;
|
||||||
|
|||||||
+5
@@ -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:');
|
||||||
|
|||||||
+42
@@ -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}$'");
|
||||||
|
}
|
||||||
|
});
|
||||||
+12
@@ -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");
|
||||||
|
});
|
||||||
+167
@@ -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.');
|
||||||
|
|||||||
Reference in New Issue
Block a user