Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76dddad410 |
+3
-44
@@ -40,8 +40,6 @@ tags:
|
||||
description: Account security and passkey management endpoints
|
||||
- name: Users
|
||||
description: User management and customer operations
|
||||
- name: Limited Backoffice
|
||||
description: Limited backoffice employee and department management
|
||||
- name: Search
|
||||
description: System-wide search endpoints
|
||||
- name: Orders
|
||||
@@ -2764,44 +2762,6 @@ paths:
|
||||
properties:
|
||||
token: {type: string}
|
||||
|
||||
/limited-backoffice/employees/{employeeId}/login-link:
|
||||
post:
|
||||
tags:
|
||||
- Limited Backoffice
|
||||
summary: Create a managed employee QR login link
|
||||
description: Create a reusable auth-token login link for an active employee managed through the limited backoffice.
|
||||
operationId: createLimitedBackofficeEmployeeLoginLink
|
||||
parameters:
|
||||
- name: employeeId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Login link created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
employee_id:
|
||||
type: integer
|
||||
login_path:
|
||||
type: string
|
||||
example: /login/qr?token=abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
|
||||
# User Endpoints
|
||||
/users:
|
||||
get:
|
||||
@@ -13428,6 +13388,7 @@ components:
|
||||
- expected
|
||||
- actual
|
||||
- data_collection_accepted
|
||||
- screenshot
|
||||
properties:
|
||||
before_error:
|
||||
type: string
|
||||
@@ -13443,11 +13404,10 @@ components:
|
||||
description: What actually happened
|
||||
data_collection_accepted:
|
||||
type: boolean
|
||||
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
|
||||
description: Required acceptance of collecting screenshot and diagnostic error data
|
||||
screenshot:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
|
||||
description: PNG, JPEG, or WebP data URI of the current app viewport
|
||||
route_path:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -13558,7 +13518,6 @@ components:
|
||||
nullable: true
|
||||
screenshot:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
answers:
|
||||
type: object
|
||||
|
||||
@@ -928,20 +928,22 @@ async function main() {
|
||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
|
||||
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
|
||||
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
|
||||
},
|
||||
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
|
||||
assert.ok(
|
||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||
"Gateway logs page did not persist the shell transcript."
|
||||
);
|
||||
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
assert.ok(
|
||||
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||
"Gateway logs page did not include the shell close audit event."
|
||||
);
|
||||
|
||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||
|
||||
@@ -137,10 +137,6 @@ class customer_mass_import_service
|
||||
if ($cvrLength < 8 || $cvrLength > 20) {
|
||||
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
|
||||
}
|
||||
|
||||
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
|
||||
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizePositiveInt(mixed $value): ?int
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
if ($message !== null) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$context = self::loadOrderProductContext($orderId, $productId);
|
||||
if ($context === null) {
|
||||
return null;
|
||||
}
|
||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||
? self::ONLY_TANKCLEANING_MESSAGE
|
||||
: null;
|
||||
}
|
||||
|
||||
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||
{
|
||||
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||
}
|
||||
|
||||
public static function isTankCleaningProductRow(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1 || $productId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
o.customer_id AS customer_number,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name,
|
||||
p.category AS product_category,
|
||||
c.name AS category_name,
|
||||
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||
FROM orders o
|
||||
LEFT JOIN products p ON p.id = {$productId}
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||
WHERE o.id = {$orderId}
|
||||
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
));
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,6 @@ class departments_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
||||
AFTER archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
|
||||
@@ -172,8 +172,7 @@ class economic implements economic_i
|
||||
string $email,
|
||||
int $phone,
|
||||
?int $mobile_phone = null,
|
||||
object|array|null $company_information = null,
|
||||
?string $ean = null
|
||||
object|array|null $company_information = null
|
||||
): object
|
||||
{
|
||||
$payload = [
|
||||
@@ -197,37 +196,10 @@ class economic implements economic_i
|
||||
];
|
||||
|
||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||
$normalized_ean = self::normalizeCustomerEan($ean);
|
||||
if ($normalized_ean !== null) {
|
||||
$payload['ean'] = $normalized_ean;
|
||||
}
|
||||
|
||||
return $this->customers->customers->create($payload);
|
||||
}
|
||||
|
||||
public static function normalizeCustomerEan(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', (string)$value);
|
||||
if (!is_string($digits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = trim($digits);
|
||||
if ($digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($digits) > 13) {
|
||||
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
|
||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||
{
|
||||
if ($company_information === null) {
|
||||
|
||||
@@ -240,13 +240,7 @@ class economic_transfer_executor
|
||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||
);
|
||||
|
||||
$result = $collected_order_invoices->asArray();
|
||||
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
||||
if ($transfer_metrics !== null) {
|
||||
$result['economic_transfer_metrics'] = $transfer_metrics;
|
||||
}
|
||||
|
||||
return $result;
|
||||
return $collected_order_invoices->asArray();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -389,15 +389,6 @@ class economic_v2_distribution_service
|
||||
}
|
||||
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
|
||||
if ($discount_row === null) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||
$fixed_price = (float)$discount_row['fixed_price'];
|
||||
$order_discount_total += (($base_price - $fixed_price) * $quantity);
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage <= 0) {
|
||||
continue;
|
||||
@@ -1529,12 +1520,6 @@ class economic_v2_distribution_service
|
||||
}
|
||||
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
|
||||
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
|
||||
$total += $line_price;
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage > 0) {
|
||||
$line_price *= (1 - ($discount_percentage / 100));
|
||||
@@ -1552,10 +1537,7 @@ class economic_v2_distribution_service
|
||||
}
|
||||
|
||||
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
|
||||
if ($direct !== null && (
|
||||
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|
||||
|| (int)($direct['discount'] ?? 0) > 0
|
||||
)) {
|
||||
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $direct;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ class economic_v2_schema_bootstrap
|
||||
is_category TINYINT(1) NOT NULL,
|
||||
object_id VARCHAR(64) NOT NULL,
|
||||
discount INT NOT NULL,
|
||||
fixed_price INT NULL DEFAULT NULL,
|
||||
effective_from DATETIME NOT NULL,
|
||||
effective_to DATETIME NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'live',
|
||||
@@ -84,14 +83,6 @@ class economic_v2_schema_bootstrap
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
|
||||
$db->query(
|
||||
"ALTER TABLE customer_discount_override_versions
|
||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
||||
AFTER discount"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -115,3 +106,4 @@ class economic_v2_schema_bootstrap
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,8 +135,7 @@ class economic_v2_versioning_service
|
||||
string $source = 'live.discount_override',
|
||||
float $confidence = 1.0,
|
||||
bool $inferred = false,
|
||||
array $metadata = [],
|
||||
?int $fixed_price = null
|
||||
array $metadata = []
|
||||
): array {
|
||||
$identity = [
|
||||
'user_id' => $user_id,
|
||||
@@ -145,7 +144,7 @@ class economic_v2_versioning_service
|
||||
'object_id' => (string)$object_id,
|
||||
];
|
||||
|
||||
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
|
||||
if ($discount === null || (int)$discount === 0) {
|
||||
return $this->closeActiveVersion(
|
||||
'customer_discount_override_versions',
|
||||
$identity,
|
||||
@@ -162,7 +161,6 @@ class economic_v2_versioning_service
|
||||
$identity,
|
||||
[
|
||||
'discount' => (int)$discount,
|
||||
'fixed_price' => $is_category ? null : $fixed_price,
|
||||
],
|
||||
$this->normalizeDatetime($effective_from),
|
||||
$source,
|
||||
@@ -413,11 +411,8 @@ class economic_v2_versioning_service
|
||||
}
|
||||
|
||||
// Discount overrides current state.
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
|
||||
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
|
||||
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
|
||||
($has_override_fixed_price ? ', po.fixed_price' : '') .
|
||||
($has_override_created_at ? ', po.created_at' : '');
|
||||
$discount_rows = $this->fetchAll(
|
||||
"SELECT $discount_cols
|
||||
@@ -439,8 +434,7 @@ class economic_v2_versioning_service
|
||||
'backfill.current_discount_override',
|
||||
$confidence,
|
||||
true,
|
||||
['table' => 'price_overrides'],
|
||||
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
|
||||
['table' => 'price_overrides']
|
||||
);
|
||||
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
|
||||
}
|
||||
@@ -713,3 +707,4 @@ class economic_v2_versioning_service
|
||||
$bucket[$action]++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,17 +92,12 @@ class error_report_service
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
$runtimeContext['screenshot_attachment'] = [
|
||||
'status' => $storedScreenshot['status'],
|
||||
'attached' => $storedScreenshot['key'] !== '',
|
||||
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
|
||||
'size_bytes' => (int)$storedScreenshot['size_bytes'],
|
||||
];
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
@@ -300,67 +295,6 @@ class error_report_service
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function storeOptionalScreenshot(mixed $value, array $context): array
|
||||
{
|
||||
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
|
||||
return $this->emptyScreenshotAttachment('invalid');
|
||||
}
|
||||
|
||||
$dataUri = trim((string)($value ?? ''));
|
||||
if ($dataUri === '') {
|
||||
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
|
||||
}
|
||||
|
||||
try {
|
||||
$screenshot = self::decodeScreenshotDataUri($dataUri);
|
||||
} catch (RuntimeException $exception) {
|
||||
$message = strtolower($exception->getMessage());
|
||||
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
} catch (Throwable) {
|
||||
return $this->emptyScreenshotAttachment('storage_failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => (string)($storedScreenshot['key'] ?? ''),
|
||||
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
|
||||
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
|
||||
'status' => 'stored',
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyScreenshotAttachment(string $status): array
|
||||
{
|
||||
return [
|
||||
'key' => '',
|
||||
'mime_type' => '',
|
||||
'size_bytes' => 0,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
private function contextScreenshotStatus(array $context): ?string
|
||||
{
|
||||
$attachment = $context['screenshot_attachment'] ?? null;
|
||||
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
|
||||
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
|
||||
|
||||
return $this->normalizeEmptyScreenshotStatus($status);
|
||||
}
|
||||
|
||||
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
@@ -498,10 +432,6 @@ class error_report_service
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
|
||||
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
|
||||
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
|
||||
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
@@ -519,10 +449,10 @@ class error_report_service
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => $hasScreenshot ? [
|
||||
'mime_type' => $screenshotMimeType,
|
||||
'size_bytes' => $screenshotSizeBytes,
|
||||
] : null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
@@ -537,11 +467,8 @@ class error_report_service
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
if ($hasScreenshot) {
|
||||
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
||||
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
||||
}
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
|
||||
@@ -30,7 +30,6 @@ class invoice_period_flag_service
|
||||
public function __construct()
|
||||
{
|
||||
invoice_period_flag_schema_bootstrap::ensureTables();
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
}
|
||||
|
||||
public function createManualFlag(array $payload, int $userId): array
|
||||
@@ -689,7 +688,6 @@ class invoice_period_flag_service
|
||||
o.po AS order_po,
|
||||
o.notes AS order_notes,
|
||||
o.department_id,
|
||||
d.custom_pricing_only AS department_custom_pricing_only,
|
||||
o.reg_1,
|
||||
o.invoice_collection_id,
|
||||
o.wash_id,
|
||||
@@ -713,7 +711,6 @@ class invoice_period_flag_service
|
||||
c.name AS category_name,
|
||||
pdp.price AS department_price,
|
||||
product_discount.percentage AS product_discount_percentage,
|
||||
product_discount.fixed_price AS product_fixed_price,
|
||||
category_discount.percentage AS category_discount_percentage
|
||||
FROM orders o
|
||||
LEFT JOIN (
|
||||
@@ -723,12 +720,11 @@ class invoice_period_flag_service
|
||||
GROUP BY customer_number
|
||||
) u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN departments d ON d.id = o.department_id
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
|
||||
FROM price_overrides po
|
||||
INNER JOIN users discount_user ON discount_user.id = po.user_id
|
||||
WHERE po.is_category = 0
|
||||
@@ -1925,19 +1921,7 @@ class invoice_period_flag_service
|
||||
|
||||
private function calculateExpectedPrice(array $row): int
|
||||
{
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
if ($customMissingPrice) {
|
||||
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
}
|
||||
|
||||
$fixedPrice = $this->rowProductFixedPrice($row);
|
||||
if ($fixedPrice !== null) {
|
||||
return $fixedPrice;
|
||||
}
|
||||
|
||||
$base = $row['department_price'] !== null
|
||||
? (int)$row['department_price']
|
||||
: (int)($row['product_base_price'] ?? 0);
|
||||
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||
return (int)round($base * (1 - ($discount / 100)));
|
||||
}
|
||||
@@ -1945,18 +1929,13 @@ class invoice_period_flag_service
|
||||
private function priceBreakdown(array $row, int $expected): array
|
||||
{
|
||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
||||
$discount = $this->discountBreakdown($row);
|
||||
if ($customMissingPrice) {
|
||||
$discount['applied_discount_percentage'] = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
||||
'product_price' => (int)($row['product_base_price'] ?? 0),
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_base_price' => $base,
|
||||
'product_fixed_price' => $this->rowProductFixedPrice($row),
|
||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||
'category_discount_percentage' => $discount['category_discount_percentage'],
|
||||
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
|
||||
@@ -1965,36 +1944,21 @@ class invoice_period_flag_service
|
||||
];
|
||||
}
|
||||
|
||||
private function isCustomMissingDepartmentPrice(array $row): bool
|
||||
{
|
||||
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
private function discountBreakdown(array $row): array
|
||||
{
|
||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
|
||||
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
|
||||
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
|
||||
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
|
||||
? 0
|
||||
: max($productDiscount, $categoryDiscount, $economicDiscount);
|
||||
|
||||
return [
|
||||
'product_discount_percentage' => $productDiscount,
|
||||
'category_discount_percentage' => $categoryDiscount,
|
||||
'economic_customer_discount_percentage' => $economicDiscount,
|
||||
'applied_discount_percentage' => $appliedDiscount,
|
||||
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
|
||||
];
|
||||
}
|
||||
|
||||
private function rowProductFixedPrice(array $row): ?int
|
||||
{
|
||||
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
|
||||
? (int)$row['product_fixed_price']
|
||||
: null;
|
||||
}
|
||||
|
||||
private function economicCustomerDiscountPercentage(array $row): int
|
||||
{
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
@@ -2072,7 +2036,8 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
namespace classes;
|
||||
|
||||
use mysqli;
|
||||
use objects\logs_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class limited_backoffice_service
|
||||
@@ -13,18 +11,6 @@ class limited_backoffice_service
|
||||
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
|
||||
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
|
||||
|
||||
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
|
||||
|
||||
/**
|
||||
* Permissions required for managed employees to sign in and appear in the employee login picker.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
|
||||
'user',
|
||||
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{label:string,description:string,permissions:array<int,string>}>
|
||||
*/
|
||||
@@ -126,134 +112,6 @@ class limited_backoffice_service
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{group:string,capability:string}>
|
||||
*/
|
||||
private const ROLE_PERMISSION_CAPABILITIES = [
|
||||
'user' => [
|
||||
'group' => 'account',
|
||||
'capability' => 'sign_in',
|
||||
],
|
||||
'permissions_list_own' => [
|
||||
'group' => 'account',
|
||||
'capability' => 'view_own_permissions',
|
||||
],
|
||||
'list_orders' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'view_orders',
|
||||
],
|
||||
'add_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'create_orders',
|
||||
],
|
||||
'edit_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'edit_orders',
|
||||
],
|
||||
'delete_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'delete_orders',
|
||||
],
|
||||
'list_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'view_order_items',
|
||||
],
|
||||
'add_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'create_order_items',
|
||||
],
|
||||
'edit_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'update_order_lines',
|
||||
],
|
||||
'delete_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'remove_order_lines',
|
||||
],
|
||||
'charge_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'charge_orders',
|
||||
],
|
||||
'list_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'view_department_bookings',
|
||||
],
|
||||
'list_own_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'view_own_bookings',
|
||||
],
|
||||
'edit_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'update_bookings',
|
||||
],
|
||||
'add_booking' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'create_bookings',
|
||||
],
|
||||
'complete_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'mark_bookings_complete',
|
||||
],
|
||||
'resend_booking_confirmations' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'send_booking_confirmations',
|
||||
],
|
||||
'department_timebookings_entries_get' => [
|
||||
'group' => 'time_bookings',
|
||||
'capability' => 'view_time_booking_entries',
|
||||
],
|
||||
'department_timebookings_entries_post' => [
|
||||
'group' => 'time_bookings',
|
||||
'capability' => 'create_time_booking_entries',
|
||||
],
|
||||
'department_timebookings_entries_put' => [
|
||||
'group' => 'time_bookings',
|
||||
'capability' => 'edit_time_booking_entries',
|
||||
],
|
||||
'statistics_orders_new' => [
|
||||
'group' => 'reports',
|
||||
'capability' => 'view_order_statistics',
|
||||
],
|
||||
'statistics_bookings_new' => [
|
||||
'group' => 'reports',
|
||||
'capability' => 'view_booking_statistics',
|
||||
],
|
||||
self::PERMISSION_ACCESS => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'open_limited_backoffice',
|
||||
],
|
||||
self::PERMISSION_MANAGE_PRICES => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'manage_department_prices',
|
||||
],
|
||||
self::PERMISSION_MANAGE_EMPLOYEES => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'manage_employee_access',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ROLE_PERMISSION_GROUP_ORDER = [
|
||||
'account',
|
||||
'orders',
|
||||
'bookings',
|
||||
'time_bookings',
|
||||
'reports',
|
||||
'limited_backoffice',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, true>
|
||||
*/
|
||||
private const PHONE_COUNTRY_CODES = [
|
||||
45 => true,
|
||||
46 => true,
|
||||
47 => true,
|
||||
358 => true,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
@@ -261,64 +119,25 @@ class limited_backoffice_service
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
departments_schema_bootstrap::ensureTables();
|
||||
limited_backoffice_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,string>}>}>
|
||||
* @return array<int, array{key:string,label:string,description:string}>
|
||||
*/
|
||||
public function rolePresets(?users_o $manager = null): array
|
||||
public function rolePresets(): array
|
||||
{
|
||||
$roles = [];
|
||||
foreach (self::ROLE_PRESETS as $key => $preset) {
|
||||
$permissions = $manager === null
|
||||
? $preset['permissions']
|
||||
: $this->effectiveRolePermissionsForManager($manager, $key, false);
|
||||
|
||||
$roles[] = [
|
||||
'key' => $key,
|
||||
'label' => $preset['label'],
|
||||
'description' => $preset['description'],
|
||||
'permission_groups' => $this->rolePermissionGroups($permissions),
|
||||
];
|
||||
}
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
private function rolePermissionGroups(array $permissions): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$capability = self::ROLE_PERMISSION_CAPABILITIES[$permission] ?? null;
|
||||
if ($capability === null) {
|
||||
throw new \RuntimeException('Missing limited backoffice role capability for permission: ' . $permission);
|
||||
}
|
||||
|
||||
$group = $capability['group'];
|
||||
$groups[$group] ??= [];
|
||||
$groups[$group][] = $capability['capability'];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach (self::ROLE_PERMISSION_GROUP_ORDER as $group) {
|
||||
if (!isset($groups[$group])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($groups[$group])),
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -375,7 +194,7 @@ class limited_backoffice_service
|
||||
|
||||
$in = implode(',', array_map('intval', $departmentIds));
|
||||
$sql = "
|
||||
SELECT `id`, `name`, `description`, `visible`, `archived`, `custom_pricing_only`
|
||||
SELECT `id`, `name`, `description`, `visible`, `archived`
|
||||
FROM `departments`
|
||||
WHERE `id` IN ($in)
|
||||
ORDER BY `order_priority` ASC, `name` ASC, `id` ASC
|
||||
@@ -390,7 +209,6 @@ class limited_backoffice_service
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'visible' => (bool)($row['visible'] ?? false),
|
||||
'archived' => (bool)($row['archived'] ?? false),
|
||||
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
@@ -406,9 +224,8 @@ class limited_backoffice_service
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
|
||||
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
|
||||
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
|
||||
if (!$customPricingOnly && $catalog['missing_products'] !== []) {
|
||||
$catalog = $this->departmentProductCatalog($departmentId);
|
||||
if ($catalog['missing_products'] !== []) {
|
||||
throw new limited_backoffice_exception('Department price setup is incomplete.', 409, [
|
||||
'message' => 'Department price setup is incomplete.',
|
||||
'code' => 'department_price_setup_required',
|
||||
@@ -440,8 +257,7 @@ class limited_backoffice_service
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
|
||||
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
|
||||
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
|
||||
$catalog = $this->departmentProductCatalog($departmentId);
|
||||
if ($catalog['required_product_ids'] === []) {
|
||||
throw new limited_backoffice_exception('Department has no products configured.', 409);
|
||||
}
|
||||
@@ -458,7 +274,7 @@ class limited_backoffice_service
|
||||
sort($providedProductIds);
|
||||
$missingProductIds = array_values(array_diff($requiredProductIds, $providedProductIds));
|
||||
|
||||
if (!$customPricingOnly && $missingProductIds !== []) {
|
||||
if ($missingProductIds !== []) {
|
||||
throw new limited_backoffice_exception('Price is required for every department product.', 400, [
|
||||
'message' => 'Price is required for every department product.',
|
||||
'missing_product_ids' => $missingProductIds,
|
||||
@@ -475,26 +291,26 @@ class limited_backoffice_service
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
$deleteStatement = $mysqli->prepare(
|
||||
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
|
||||
$priceUpdateAssignments = ['`price` = VALUES(`price`)'];
|
||||
if ($this->tableHasColumn('product_department_prices', 'updated_at')) {
|
||||
$priceUpdateAssignments[] = '`updated_at` = CURRENT_TIMESTAMP';
|
||||
}
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE ' . implode(', ', $priceUpdateAssignments)
|
||||
);
|
||||
$insertStatement = $mysqli->prepare(
|
||||
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`) VALUES (?, ?, ?)'
|
||||
);
|
||||
if ($deleteStatement === false || $insertStatement === false) {
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare department price update.');
|
||||
}
|
||||
|
||||
foreach ($normalizedPrices as $productId => $price) {
|
||||
$deleteStatement->bind_param('ii', $departmentId, $productId);
|
||||
$deleteStatement->execute();
|
||||
|
||||
$insertStatement->bind_param('iii', $departmentId, $productId, $price);
|
||||
$insertStatement->execute();
|
||||
$statement->bind_param('iii', $departmentId, $productId, $price);
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
$deleteStatement->close();
|
||||
$insertStatement->close();
|
||||
$statement->close();
|
||||
$mysqli->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$mysqli->rollback();
|
||||
@@ -566,8 +382,7 @@ class limited_backoffice_service
|
||||
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
|
||||
$displayName = $this->normalizeRequiredString($payload['display_name'] ?? null, 'Display name is required.');
|
||||
$password = $this->normalizePassword($payload['password'] ?? null, true);
|
||||
$email = $this->normalizeEmail($payload['email'] ?? null, true);
|
||||
$phone = $this->normalizeOptionalPhonePair($payload);
|
||||
$email = $this->normalizeOptionalString($payload['email'] ?? null);
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
$mysqli->begin_transaction();
|
||||
@@ -578,23 +393,13 @@ class limited_backoffice_service
|
||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `users`
|
||||
(`customer_number`, `display_name`, `email`, `password`, `group_id`, `phone_country_code`, `phone`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO `users` (`customer_number`, `display_name`, `email`, `password`, `group_id`)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee insert.');
|
||||
}
|
||||
$statement->bind_param(
|
||||
'isssiii',
|
||||
$customerNumber,
|
||||
$displayName,
|
||||
$email,
|
||||
$passwordHash,
|
||||
$groupId,
|
||||
$phone['phone_country_code'],
|
||||
$phone['phone']
|
||||
);
|
||||
$statement->bind_param('isssi', $customerNumber, $displayName, $email, $passwordHash, $groupId);
|
||||
$statement->execute();
|
||||
$employeeId = (int)$mysqli->insert_id;
|
||||
$statement->close();
|
||||
@@ -672,12 +477,11 @@ class limited_backoffice_service
|
||||
? $this->normalizeRequiredString($payload['display_name'], 'Display name is required.')
|
||||
: null;
|
||||
$email = array_key_exists('email', $payload)
|
||||
? $this->normalizeEmail($payload['email'], true)
|
||||
? $this->normalizeOptionalString($payload['email'])
|
||||
: null;
|
||||
$password = array_key_exists('password', $payload)
|
||||
? $this->normalizePassword($payload['password'], false)
|
||||
: null;
|
||||
$phone = $this->normalizeOptionalPhonePair($payload, false);
|
||||
$active = array_key_exists('active', $payload)
|
||||
? (bool)$payload['active']
|
||||
: $this->isEmployeeRowActive($employee);
|
||||
@@ -694,7 +498,7 @@ class limited_backoffice_service
|
||||
|
||||
try {
|
||||
if ($active) {
|
||||
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
|
||||
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($roleKey, $newDepartmentIds));
|
||||
}
|
||||
|
||||
$userUpdates = [];
|
||||
@@ -707,10 +511,6 @@ class limited_backoffice_service
|
||||
if ($password !== null) {
|
||||
$userUpdates['password'] = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
if ($phone !== null) {
|
||||
$userUpdates['phone_country_code'] = $phone['phone_country_code'];
|
||||
$userUpdates['phone'] = $phone['phone'];
|
||||
}
|
||||
$usersHaveDeletedAt = $this->tableHasColumn('users', 'deleted_at');
|
||||
if ($active) {
|
||||
$userUpdates['group_id'] = $managedGroupId;
|
||||
@@ -767,47 +567,6 @@ class limited_backoffice_service
|
||||
return $this->updateEmployee($manager, $employeeId, ['active' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{employee_id:int,login_path:string}
|
||||
*/
|
||||
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
|
||||
{
|
||||
$this->assertNotSelfEdit($manager, $employeeId);
|
||||
|
||||
$employee = $this->loadManagedEmployee($employeeId);
|
||||
if ($employee === null) {
|
||||
throw new limited_backoffice_exception('Managed employee not found.', 404);
|
||||
}
|
||||
|
||||
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
|
||||
$this->assertDepartmentSubset($manager, $departmentIds);
|
||||
$this->assertManagedTargetIsSafe($employee);
|
||||
|
||||
if (!$this->isEmployeeRowActive($employee)) {
|
||||
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
|
||||
}
|
||||
|
||||
$token = (new authentication())->create_employee_token($employeeId);
|
||||
|
||||
try {
|
||||
(new logs_o())->add(
|
||||
'auth',
|
||||
'global',
|
||||
1,
|
||||
(int)$manager->id,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
|
||||
'Created limited backoffice login link for employee: ' . $employeeId
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Audit logging should not block login-link generation.
|
||||
}
|
||||
|
||||
return [
|
||||
'employee_id' => $employeeId,
|
||||
'login_path' => '/login/qr?token=' . $token,
|
||||
];
|
||||
}
|
||||
|
||||
private function mysqli(): mysqli
|
||||
{
|
||||
global $db;
|
||||
@@ -852,13 +611,13 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
|
||||
* @return array{id:int,name:string,description:string}
|
||||
*/
|
||||
private function fetchDepartment(int $departmentId): ?array
|
||||
{
|
||||
global $db;
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `id`, `name`, `description`, `custom_pricing_only` FROM `departments` WHERE `id` = ? LIMIT 1'
|
||||
'SELECT `id`, `name`, `description` FROM `departments` WHERE `id` = ? LIMIT 1'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to load department.', 500);
|
||||
@@ -877,14 +636,13 @@ class limited_backoffice_service
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{categories:array<int,array<string,mixed>>,missing_products:array<int,array<string,mixed>>,required_product_ids:array<int,int>}
|
||||
*/
|
||||
private function departmentProductCatalog(int $departmentId, bool $customPricingOnly = false): array
|
||||
private function departmentProductCatalog(int $departmentId): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -910,14 +668,8 @@ class limited_backoffice_service
|
||||
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
|
||||
INNER JOIN `products` p ON p.`category` = dc.`category_id`
|
||||
LEFT JOIN `product_department_prices` pdp
|
||||
ON pdp.`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
|
||||
)
|
||||
ON pdp.`department_id` = dc.`department_id`
|
||||
AND pdp.`product_id` = p.`id`
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC'
|
||||
);
|
||||
@@ -933,15 +685,9 @@ class limited_backoffice_service
|
||||
$categories = [];
|
||||
$missing = [];
|
||||
$requiredProductIds = [];
|
||||
$seenProductIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$categoryId = (int)$row['category_id'];
|
||||
$productId = (int)$row['product_id'];
|
||||
|
||||
if (isset($seenProductIds[$productId])) {
|
||||
continue;
|
||||
}
|
||||
$seenProductIds[$productId] = true;
|
||||
$requiredProductIds[] = $productId;
|
||||
|
||||
if (!isset($categories[$categoryId])) {
|
||||
@@ -957,12 +703,10 @@ class limited_backoffice_service
|
||||
'id' => $productId,
|
||||
'name' => (string)$row['product_name'],
|
||||
'description' => (string)($row['product_description'] ?? ''),
|
||||
'price' => $row['department_price'] === null
|
||||
? ($customPricingOnly ? products_o::CUSTOM_PRICING_MISSING_PRICE : null)
|
||||
: (int)$row['department_price'],
|
||||
'price' => $row['department_price'] === null ? null : (int)$row['department_price'],
|
||||
];
|
||||
|
||||
if ($row['department_price_id'] === null && !$customPricingOnly) {
|
||||
if ($row['department_price_id'] === null) {
|
||||
$missing[] = [
|
||||
'id' => $productId,
|
||||
'name' => (string)$row['product_name'],
|
||||
@@ -981,7 +725,7 @@ class limited_backoffice_service
|
||||
return [
|
||||
'categories' => array_values($categories),
|
||||
'missing_products' => $missing,
|
||||
'required_product_ids' => array_values($requiredProductIds),
|
||||
'required_product_ids' => array_values(array_unique($requiredProductIds)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1165,89 +909,6 @@ class limited_backoffice_service
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function normalizeEmail(mixed $value, bool $required): ?string
|
||||
{
|
||||
$email = $this->normalizeOptionalString($value);
|
||||
if ($email === null) {
|
||||
if ($required) {
|
||||
throw new limited_backoffice_exception('Email is required.', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw new limited_backoffice_exception('Email must be a valid email address.', 400);
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{phone_country_code:int|null,phone:int|null}|null
|
||||
*/
|
||||
private function normalizeOptionalPhonePair(array $payload, bool $defaultWhenMissing = true): ?array
|
||||
{
|
||||
$hasCountryCode = array_key_exists('phone_country_code', $payload);
|
||||
$hasPhone = array_key_exists('phone', $payload);
|
||||
if (!$hasCountryCode && !$hasPhone) {
|
||||
return $defaultWhenMissing
|
||||
? ['phone_country_code' => null, 'phone' => null]
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!$hasCountryCode || !$hasPhone) {
|
||||
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
|
||||
}
|
||||
|
||||
$countryCode = $this->normalizeOptionalDigits($payload['phone_country_code']);
|
||||
$phone = $this->normalizeOptionalDigits($payload['phone']);
|
||||
if ($countryCode === null && $phone === null) {
|
||||
return ['phone_country_code' => null, 'phone' => null];
|
||||
}
|
||||
|
||||
if ($countryCode === null || $phone === null) {
|
||||
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
|
||||
}
|
||||
|
||||
if (!isset(self::PHONE_COUNTRY_CODES[$countryCode])) {
|
||||
throw new limited_backoffice_exception('Phone country code is not supported.', 400);
|
||||
}
|
||||
|
||||
$phoneText = (string)$phone;
|
||||
if (!preg_match('/^\d{4,15}$/', $phoneText)) {
|
||||
throw new limited_backoffice_exception('Phone number must be 4-15 digits.', 400);
|
||||
}
|
||||
|
||||
return [
|
||||
'phone_country_code' => $countryCode,
|
||||
'phone' => $phone,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeOptionalDigits(mixed $value): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (ctype_digit($value)) {
|
||||
return (int)$value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new limited_backoffice_exception('Phone values must contain digits only.', 400);
|
||||
}
|
||||
|
||||
private function normalizePassword(mixed $value, bool $required): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
@@ -1290,39 +951,18 @@ class limited_backoffice_service
|
||||
$groupId = (int)$this->mysqli()->insert_id;
|
||||
$statement->close();
|
||||
|
||||
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $departmentIds));
|
||||
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($roleKey, $departmentIds));
|
||||
|
||||
return $groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function effectiveRolePermissionsForManager(users_o $manager, string $roleKey, bool $includePublicVisibility): array
|
||||
{
|
||||
$permissions = $includePublicVisibility ? self::MANAGED_EMPLOYEE_BASE_PERMISSIONS : ['user'];
|
||||
|
||||
foreach (self::ROLE_PRESETS[$roleKey]['permissions'] ?? [] as $permission) {
|
||||
if (in_array($permission, self::MANAGED_EMPLOYEE_BASE_PERMISSIONS, true)) {
|
||||
$permissions[] = $permission;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($manager->hasPermission($permission)) {
|
||||
$permissions[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $departmentIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function permissionsForRoleAndDepartments(users_o $manager, string $roleKey, array $departmentIds): array
|
||||
private function permissionsForRoleAndDepartments(string $roleKey, array $departmentIds): array
|
||||
{
|
||||
$permissions = $this->effectiveRolePermissionsForManager($manager, $roleKey, true);
|
||||
$permissions = self::ROLE_PRESETS[$roleKey]['permissions'] ?? [];
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$permissions[] = 'department_access_' . $departmentId;
|
||||
}
|
||||
@@ -1441,12 +1081,9 @@ class limited_backoffice_service
|
||||
{
|
||||
return [
|
||||
'id' => (int)$row['user_id'],
|
||||
'user_id' => (int)$row['user_id'],
|
||||
'customer_number' => (int)$row['customer_number'],
|
||||
'display_name' => (string)($row['display_name'] ?? ''),
|
||||
'email' => $row['email'] === null ? null : (string)$row['email'],
|
||||
'phone_country_code' => $row['phone_country_code'] === null ? null : (int)$row['phone_country_code'],
|
||||
'phone' => $row['phone'] === null ? null : (int)$row['phone'],
|
||||
'active' => $active,
|
||||
'role' => $this->rolePayload((string)$row['role_key']),
|
||||
'departments' => $this->departmentSummaries($departmentIds),
|
||||
@@ -1567,7 +1204,7 @@ class limited_backoffice_service
|
||||
$types = '';
|
||||
$values = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at', 'phone_country_code', 'phone'], true)) {
|
||||
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at'], true)) {
|
||||
continue;
|
||||
}
|
||||
if ($value === null) {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?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,7 +499,6 @@ class system_search_document_index
|
||||
*/
|
||||
private function buildCustomerDiscountDocuments(): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
|
||||
$selectFields = [
|
||||
'po.id AS entity_id',
|
||||
@@ -507,7 +506,6 @@ class system_search_document_index
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
@@ -556,7 +554,6 @@ class system_search_document_index
|
||||
$row['search_text'] ?? null,
|
||||
$row['product_or_category_id'] ?? null,
|
||||
$row['percentage'] ?? null,
|
||||
$row['fixed_price'] ?? null,
|
||||
$row['user_id'] ?? null,
|
||||
]),
|
||||
$this->toIntOrNull($row['customer_number'] ?? null),
|
||||
@@ -567,7 +564,6 @@ class system_search_document_index
|
||||
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
|
||||
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
'is_category' => $row['is_category'] ?? null,
|
||||
|
||||
@@ -1086,7 +1086,6 @@ class system_search_service
|
||||
|
||||
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$customerFilter = '';
|
||||
if (!empty($forcedCustomerNumbers)) {
|
||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
|
||||
@@ -1101,12 +1100,11 @@ class system_search_service
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
@@ -1168,7 +1166,6 @@ class system_search_service
|
||||
'search_text',
|
||||
'product_or_category_id',
|
||||
'percentage',
|
||||
'fixed_price',
|
||||
'user_id',
|
||||
], $terms) + $entityBoost,
|
||||
'payload' => $this->augmentPayloadWithTemporal([
|
||||
@@ -1176,7 +1173,6 @@ class system_search_service
|
||||
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
|
||||
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
], $row),
|
||||
|
||||
@@ -14,8 +14,6 @@ class economic_customer_mo
|
||||
public null|string $message;
|
||||
public null|string $corporateIdentificationNumber;
|
||||
public null|string $email;
|
||||
public null|string $ean;
|
||||
public null|string $publicEntryNumber;
|
||||
public null|string $mobilePhone;
|
||||
public null|string $currency;
|
||||
public null|string $country;
|
||||
@@ -48,8 +46,6 @@ class economic_customer_mo
|
||||
$this->zip = ($customer->zip ?? null);
|
||||
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
|
||||
$this->email = ($customer->email ?? null);
|
||||
$this->ean = ($customer->ean ?? null);
|
||||
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
|
||||
$this->mobilePhone = ($customer->mobilePhone ?? null);
|
||||
$this->currency = ($customer->currency ?? null);
|
||||
$this->country = ($customer->country ?? null);
|
||||
@@ -104,8 +100,6 @@ class economic_customer_mo
|
||||
'zip' => $this->zip,
|
||||
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
|
||||
'email' => $this->email,
|
||||
'ean' => $this->ean,
|
||||
'publicEntryNumber' => $this->publicEntryNumber,
|
||||
'mobilePhone' => $this->mobilePhone,
|
||||
'currency' => $this->currency,
|
||||
'country' => $this->country,
|
||||
|
||||
+5
-33
@@ -61,47 +61,19 @@ class economic_invoices_draft_endpoint
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
||||
{
|
||||
$this->add_orders($invoiceDraftId, [$order], $currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add many orders to a draft invoice and flush their lines in batches.
|
||||
*
|
||||
* @param orders_o[] $orders
|
||||
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
|
||||
{
|
||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||
$orders_with_invoice_lines = 0;
|
||||
|
||||
foreach ( $orders as $order ) {
|
||||
if (!$order instanceof orders_o) {
|
||||
throw new Exception('Order payload must contain orders_o instances');
|
||||
}
|
||||
// Check if the order includes any items that should be included in the invoice
|
||||
if ($order->getIncludeInInvoiceCount() <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orders_with_invoice_lines++;
|
||||
// Check if the order includes any items that should be included in the invoice
|
||||
if ($order->getIncludeInInvoiceCount() > 0) {
|
||||
// Add the transaction header (Timestamp, department, etc.)
|
||||
$draftInvoice->addNewTransactionHeader($order);
|
||||
// Add the order lines
|
||||
$draftInvoice->addOrderItemLines($order);
|
||||
// Add an empty line, so the invoice is not empty
|
||||
$draftInvoice->addTextLine('');
|
||||
// Save the draft invoice lines
|
||||
$draftInvoice->addLines();
|
||||
}
|
||||
|
||||
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
|
||||
|
||||
return [
|
||||
'order_count' => count($orders),
|
||||
'orders_with_invoice_lines' => $orders_with_invoice_lines,
|
||||
...$metrics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,4 +151,4 @@ class economic_invoices_draft_endpoint
|
||||
$draft_invoice->addLines();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-19
@@ -127,23 +127,6 @@ class economic_invoices_drafts_endpoint
|
||||
$customer_address = $customer->getAddress() ?? 'Ukendt';
|
||||
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
|
||||
$customer_city = $customer->getCity() ?? 'Ukendt';
|
||||
$recipient = [
|
||||
'name' => $customer_name,
|
||||
'address' => $customer_address,
|
||||
'zip' => $customer_zip,
|
||||
'city' => $customer_city,
|
||||
'vatZone' => [
|
||||
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
|
||||
],
|
||||
];
|
||||
$customer_ean = $customer->getEan();
|
||||
if ($customer_ean !== null) {
|
||||
$recipient['ean'] = $customer_ean;
|
||||
}
|
||||
$public_entry_number = $customer->getPublicEntryNumber();
|
||||
if ($public_entry_number !== null) {
|
||||
$recipient['publicEntryNumber'] = $public_entry_number;
|
||||
}
|
||||
|
||||
// Send the request
|
||||
$response = $this->send_request(
|
||||
@@ -182,7 +165,15 @@ class economic_invoices_drafts_endpoint
|
||||
'currency' => $customer->getCurrency() ?? 'DKK',
|
||||
|
||||
// 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
|
||||
@@ -203,4 +194,4 @@ class economic_invoices_drafts_endpoint
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -149,29 +149,6 @@ class economic_customer
|
||||
return $this->customer_data_object->email;
|
||||
}
|
||||
|
||||
public function getEan(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
return $this->nullableStringField('ean');
|
||||
}
|
||||
|
||||
public function getPublicEntryNumber(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
return $this->nullableStringField('publicEntryNumber');
|
||||
}
|
||||
|
||||
protected function nullableStringField(string $field): ?string
|
||||
{
|
||||
$value = $this->customer_data_object->{$field} ?? null;
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the customer address
|
||||
* @return string The customer address
|
||||
@@ -250,4 +227,4 @@ class economic_customer
|
||||
return $this->customer_data_object->vatZone->vatZoneNumber;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ use objects\orders_o;
|
||||
|
||||
class economic_invoice_draft
|
||||
{
|
||||
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* The Economic draftInvoiceNumber
|
||||
* @var int $draft_invoice_number
|
||||
@@ -112,55 +110,13 @@ class economic_invoice_draft
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the lines to the draft invoice.
|
||||
* Add the lines to the draft invoice
|
||||
* @return void
|
||||
*/
|
||||
public function addLines(): void
|
||||
{
|
||||
$this->flushLinesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add queued draft lines using chunked requests.
|
||||
*
|
||||
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
*/
|
||||
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
|
||||
{
|
||||
$lines = array_values($this->draft_lines);
|
||||
$line_count = count($lines);
|
||||
if ($line_count === 0) {
|
||||
return [
|
||||
'line_count' => 0,
|
||||
'batch_count' => 0,
|
||||
'batch_sizes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$batch_size = max(1, $batch_size);
|
||||
$batch_sizes = [];
|
||||
foreach (array_chunk($lines, $batch_size) as $batch) {
|
||||
$this->sendDraftLines($batch);
|
||||
$batch_sizes[] = count($batch);
|
||||
}
|
||||
|
||||
$this->draft_lines = [];
|
||||
|
||||
return [
|
||||
'line_count' => $line_count,
|
||||
'batch_count' => count($batch_sizes),
|
||||
'batch_sizes' => $batch_sizes,
|
||||
];
|
||||
}
|
||||
|
||||
public function pendingLineCount(): int
|
||||
{
|
||||
return count($this->draft_lines);
|
||||
}
|
||||
|
||||
protected function sendDraftLines(array $draft_lines): object
|
||||
{
|
||||
$economic = new economic();
|
||||
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
|
||||
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,7 +37,6 @@ class collected_order_invoices_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $closed_at;
|
||||
public int $economic_wash_subscription_user_id = 1857;
|
||||
private ?array $last_economic_transfer_metrics = null;
|
||||
/**
|
||||
* The processor types
|
||||
*
|
||||
@@ -713,7 +712,6 @@ class collected_order_invoices_o extends db
|
||||
*/
|
||||
public function addInvoicesToDraft(bool $skip_check = false): self
|
||||
{
|
||||
$this->last_economic_transfer_metrics = null;
|
||||
// Require the invoice collection to be selected
|
||||
self::requireSelected();
|
||||
// Require the invoice collection to be open
|
||||
@@ -738,20 +736,10 @@ class collected_order_invoices_o extends db
|
||||
usort($orders, function ($a, $b) {
|
||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||
});
|
||||
// Add the invoice lines to the draft in one accumulated batch path.
|
||||
$order_objects = [];
|
||||
// Add the invoices to the invoice draft
|
||||
foreach ( $orders as $order ) {
|
||||
$order_object = new orders_o();
|
||||
$order_object->select((int)$order['id']);
|
||||
$order_object->requireSelected();
|
||||
$order_objects[] = $order_object;
|
||||
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
|
||||
}
|
||||
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
|
||||
$this->last_economic_transfer_metrics = [
|
||||
'draft_invoice_id' => $draft_id,
|
||||
'currency' => (string)$currency,
|
||||
...$metrics,
|
||||
];
|
||||
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
|
||||
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
||||
// Object changed
|
||||
@@ -760,11 +748,6 @@ class collected_order_invoices_o extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastEconomicTransferMetrics(): ?array
|
||||
{
|
||||
return $this->last_economic_transfer_metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
|
||||
* @param int $draft_id The invoice draft id
|
||||
|
||||
@@ -22,7 +22,6 @@ class departments_o extends db
|
||||
public object_property $dimension; // The dimension of the department
|
||||
public object_property $visible; // The visibility of the department
|
||||
public object_property $archived; // Whether the department is archived
|
||||
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
|
||||
public object_property $branding; // The branding of the department
|
||||
public object_property $longitude; // The longitude of the department (Can be null)
|
||||
public object_property $latitude; // The latitude of the department (Can be null)
|
||||
@@ -108,7 +107,6 @@ class departments_o extends db
|
||||
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
||||
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
|
||||
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
|
||||
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
|
||||
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
|
||||
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
|
||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||
@@ -187,12 +185,6 @@ class departments_o extends db
|
||||
return $department;
|
||||
}
|
||||
|
||||
public function isCustomPricingOnly(int $department_id): bool
|
||||
{
|
||||
$department = $this->getDepartmentById($department_id);
|
||||
return (bool)(int)($department['custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the price of a product in a department
|
||||
* @param int $department_id
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\customer_order_product_policy;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
@@ -94,7 +93,6 @@ class order_items_o extends db
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Avoid SQL injection
|
||||
$reference = $db->escape_string($reference);
|
||||
$notes = $db->escape_string($notes);
|
||||
@@ -169,20 +167,18 @@ class order_items_o extends db
|
||||
try {
|
||||
// Get the order
|
||||
$order = (new orders_o())->getOrderById($order_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Get the product price
|
||||
$product = (new products_o())->getProductById($product_id);
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
||||
$price = $priceResolution['price'];
|
||||
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
||||
|
||||
// Check if the user has a discount on the product, or category
|
||||
$customer = (new orders_o())->getOrderCustomer($order_id);
|
||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
|
||||
$discount = $customer->getCustomPrice($product_id, false);
|
||||
if ($discount) {
|
||||
$price = $price - ($price * $discount / 100);
|
||||
}
|
||||
|
||||
// If the price is forced, set the price to the forced price
|
||||
if ($forcePrice !== null) {
|
||||
if ($forcePrice) {
|
||||
$price = (int)$forcePrice;
|
||||
}
|
||||
|
||||
@@ -358,4 +354,4 @@ class order_items_o extends db
|
||||
{
|
||||
return (new products_o())->select((int)$this->product_id->value());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1428,16 +1428,15 @@ class orders_o extends db
|
||||
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
|
||||
$order_item->reference->set('');
|
||||
// Get the product price based on the department
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||
$product_price = (int)$priceResolution['price']; // Get the department price for the product
|
||||
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||
// Get the customers custom price discount percentage
|
||||
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
|
||||
if (!$user->exists()) {
|
||||
throw new Exception('No user found matching the customer number in the usage log');
|
||||
}
|
||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price);
|
||||
}
|
||||
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
|
||||
// Apply the discount percentage to the product price
|
||||
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
|
||||
$order_item->notes->set(null); // Set notes for the simulated order item
|
||||
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
||||
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
|
||||
@@ -1504,12 +1503,11 @@ class orders_o extends db
|
||||
if (!$current_user->exists()) {
|
||||
throw new Exception('No current user found');
|
||||
}
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||
$price = (int)$priceResolution['price']; // Get the department price for the product
|
||||
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
return $price;
|
||||
}
|
||||
return $current_user->applyProductCustomerPricing((int)$product->id, $price);
|
||||
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
|
||||
// Apply the discount percentage to the product price
|
||||
// Apply the discount percentage to the product price
|
||||
return (int)round($price * (1 - ($discount_percentage / 100)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1591,16 +1589,13 @@ class orders_o extends db
|
||||
$product_id = (int)$item['product_id'];
|
||||
if (!isset($department_price_cache[$product_id])) {
|
||||
$product = (new products_o())->select($product_id);
|
||||
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
|
||||
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
|
||||
}
|
||||
if ($tmp_user === null) {
|
||||
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||
}
|
||||
$unitPrice = (int)$department_price_cache[$product_id]['price'];
|
||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
|
||||
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false);
|
||||
}
|
||||
$post_discount = $unitPrice * $quantity;
|
||||
$discount = $tmp_user->getCustomPrice($product_id, false);
|
||||
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
|
||||
$total += $post_discount;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,6 @@ class products_o extends db
|
||||
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
|
||||
public const PRICE_SOURCE_DEPARTMENT = 'department';
|
||||
public const PRICE_SOURCE_DEFAULT = 'default';
|
||||
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
|
||||
public const PRICE_SOURCE_KEY = '_department_price_source';
|
||||
|
||||
/**
|
||||
* The name of the product
|
||||
@@ -260,41 +255,24 @@ class products_o extends db
|
||||
* @param int $department_id
|
||||
* @return array
|
||||
*/
|
||||
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
|
||||
public function applyDepartmentPricing(array $products, int $department_id): array
|
||||
{
|
||||
global $db;
|
||||
$department_id = $db->escape_string($department_id);
|
||||
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
|
||||
$result = $db->query($sql);
|
||||
$prices = $db->fetch_all($result);
|
||||
$priceLookup = [];
|
||||
foreach ($prices as $price) {
|
||||
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
|
||||
}
|
||||
|
||||
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
|
||||
foreach ( $products as $key => $product ) {
|
||||
$productId = (int)($product['id'] ?? 0);
|
||||
$source = self::PRICE_SOURCE_DEFAULT;
|
||||
if (array_key_exists($productId, $priceLookup)) {
|
||||
$products[$key]['price'] = $priceLookup[$productId];
|
||||
$source = self::PRICE_SOURCE_DEPARTMENT;
|
||||
} elseif ($customPricingOnly) {
|
||||
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
|
||||
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
|
||||
}
|
||||
|
||||
if ($includePriceSource) {
|
||||
$products[$key][self::PRICE_SOURCE_KEY] = $source;
|
||||
foreach ( $prices as $price ) {
|
||||
if ((int)$product['id'] === (int)$price['product_id']) {
|
||||
$products[$key]['price'] = $price['price'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{price:int,source:string}
|
||||
*/
|
||||
public function getDepartmentPriceResolution(int $department_id): array
|
||||
public function getDepartmentPrice(int $department_id): int
|
||||
{
|
||||
global $db;
|
||||
$department_id = $db->escape_string($department_id);
|
||||
@@ -303,27 +281,10 @@ class products_o extends db
|
||||
$prices = $db->fetch_all($result);
|
||||
// Check if the product has a department price
|
||||
if (count($prices) > 0) {
|
||||
return [
|
||||
'price' => (int)$prices[0]['price'],
|
||||
'source' => self::PRICE_SOURCE_DEPARTMENT,
|
||||
];
|
||||
}
|
||||
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
|
||||
return [
|
||||
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
|
||||
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
|
||||
];
|
||||
return $prices[0]['price'];
|
||||
}
|
||||
// Return the default price
|
||||
return [
|
||||
'price' => (int)$this->price->value(),
|
||||
'source' => self::PRICE_SOURCE_DEFAULT,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDepartmentPrice(int $department_id): int
|
||||
{
|
||||
return $this->getDepartmentPriceResolution($department_id)['price'];
|
||||
return $this->price->value();
|
||||
}
|
||||
|
||||
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
||||
@@ -339,26 +300,15 @@ class products_o extends db
|
||||
if (!isset($product['id']) || !isset($product['price'])) {
|
||||
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
|
||||
}
|
||||
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
|
||||
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
|
||||
// Get the customer's discount percentage
|
||||
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
|
||||
// Apply the discount to the product price
|
||||
if ($discount_percentage > 0) {
|
||||
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
|
||||
}
|
||||
unset($product[self::PRICE_SOURCE_KEY]);
|
||||
return $product;
|
||||
}
|
||||
|
||||
public static function stripDepartmentPriceSources(array $products): array
|
||||
{
|
||||
return array_map(static function (array $product): array {
|
||||
unset($product[self::PRICE_SOURCE_KEY]);
|
||||
return $product;
|
||||
}, $products);
|
||||
}
|
||||
|
||||
public static function priceResolutionIsCustomMissing(array $resolution): bool
|
||||
{
|
||||
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
|
||||
}
|
||||
|
||||
public function getSubscriptionMonthlyPrice(): int
|
||||
{
|
||||
// Subscription price (for 2 washes per month) is 1.2 times the normal price
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\price_overrides_schema_bootstrap;
|
||||
use classes\system_search_cache;
|
||||
use traits\db_object_t;
|
||||
|
||||
class user_price_overrides_o extends db
|
||||
@@ -16,12 +14,10 @@ class user_price_overrides_o extends db
|
||||
public object_property $is_category;
|
||||
public object_property $product_or_category_id;
|
||||
public object_property $percentage;
|
||||
public object_property $fixed_price;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
$this->setTable('price_overrides');
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
@@ -34,7 +30,6 @@ class user_price_overrides_o extends db
|
||||
$this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true);
|
||||
$this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true);
|
||||
$this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true);
|
||||
$this->fixed_price = new object_property($this->table, $this->id, 'fixed_price', 'int', false, null);
|
||||
}
|
||||
|
||||
public function setUser($user_id): user_price_overrides_o
|
||||
@@ -48,41 +43,39 @@ class user_price_overrides_o extends db
|
||||
* @param bool $is_category
|
||||
* @param int|string $product_or_category_id
|
||||
* @param int $percentage
|
||||
* @param int|null $fixed_price
|
||||
* @return $this
|
||||
*/
|
||||
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage, ?int $fixed_price = null): user_price_overrides_o
|
||||
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage): user_price_overrides_o
|
||||
{
|
||||
global $db;
|
||||
// If the user is not set, return the object
|
||||
if (!isset($this->user_id)) {
|
||||
return $this;
|
||||
}
|
||||
if ($is_category) {
|
||||
$fixed_price = null;
|
||||
}
|
||||
// Check if the record already exists
|
||||
$this->removePriceIfExist($is_category, $product_or_category_id);
|
||||
// If neither a discount nor a fixed product price is set, remove the record.
|
||||
if ($percentage === 0 && $fixed_price === null) {
|
||||
// If the percentage is 0, return the object
|
||||
if ($percentage === 0) {
|
||||
return $this;
|
||||
}
|
||||
// Create a new record in the database
|
||||
$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)";
|
||||
$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)";
|
||||
$db->query($sql);
|
||||
$this->markSearchDirty();
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
|
||||
{
|
||||
global $db;
|
||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
||||
$sql = "DELETE FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||
$db->query($sql);
|
||||
$this->markSearchDirty();
|
||||
// Get the price override from the database
|
||||
$sql = "SELECT * FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||
$result = $db->query($sql);
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
// Remove the record from the database
|
||||
$sql = "DELETE FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||
$db->query($sql);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,49 +132,6 @@ class user_price_overrides_o extends db
|
||||
return $percentage;
|
||||
}
|
||||
|
||||
public function getFixedPrice(bool $is_category, int|string $product_or_category_id): ?int
|
||||
{
|
||||
if ($is_category || !isset($this->user_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->getDirectPriceRow(false, (int)$product_or_category_id);
|
||||
if ($row === null || $row['fixed_price'] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)$row['fixed_price'];
|
||||
}
|
||||
|
||||
public function getDirectPriceRow(bool $is_category, int|string $product_or_category_id): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!isset($this->user_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
||||
$sql = "SELECT * FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id' LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['user_id'] = (int)$row['user_id'];
|
||||
$row['is_category'] = (bool)$row['is_category'];
|
||||
$row['product_or_category_id'] = $is_category
|
||||
? (string)$row['product_or_category_id']
|
||||
: (int)$row['product_or_category_id'];
|
||||
$row['percentage'] = (int)$row['percentage'];
|
||||
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null
|
||||
? (int)$row['fixed_price']
|
||||
: null;
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the price overrides for the user
|
||||
* @return array
|
||||
@@ -203,7 +153,6 @@ class user_price_overrides_o extends db
|
||||
$row['is_category'] = (bool)$row['is_category'];
|
||||
$row['product_or_category_id'] = (int)$row['product_or_category_id'];
|
||||
$row['percentage'] = (int)$row['percentage'];
|
||||
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null;
|
||||
$row['created_at'] = (string)$row['created_at'];
|
||||
$row['updated_at'] = (string)$row['updated_at'];
|
||||
// Add the row to the list
|
||||
@@ -218,19 +167,10 @@ class user_price_overrides_o extends db
|
||||
'is_category' => true,
|
||||
'product_or_category_id' => "global",
|
||||
'percentage' => (int)$economic_user_global_discount,
|
||||
'fixed_price' => null,
|
||||
'created_at' => "2021-01-01 00:00:00",
|
||||
'updated_at' => "2021-01-01 00:00:00"
|
||||
];
|
||||
}
|
||||
return $prices;
|
||||
}
|
||||
|
||||
private function markSearchDirty(): void
|
||||
{
|
||||
try {
|
||||
system_search_cache::markDirtyTable($this->table);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -981,31 +981,6 @@ class users_o extends db
|
||||
return $discount_percentage === null ? 0 : (int)$discount_percentage;
|
||||
}
|
||||
|
||||
public function getProductFixedPrice(int $product_id): ?int
|
||||
{
|
||||
self::requireSelected();
|
||||
return $this->price_overrides->setUser($this->id)->getFixedPrice(false, $product_id);
|
||||
}
|
||||
|
||||
public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true): int
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$fixed_price = $this->getProductFixedPrice($product_id);
|
||||
if ($fixed_price !== null) {
|
||||
return $fixed_price;
|
||||
}
|
||||
|
||||
$discount_percentage = $use_final_price_discount_calculation
|
||||
? (int)$this->getProductDiscountPercentage($product_id)
|
||||
: (int)$this->getCustomPrice($product_id, false);
|
||||
if ($discount_percentage <= 0) {
|
||||
return $base_price;
|
||||
}
|
||||
|
||||
return (int)round($base_price * (1 - ($discount_percentage / 100)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@@ -1102,65 +1077,9 @@ class users_o extends db
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $users
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function markLimitedBackofficeManagedUsers(array $users): array
|
||||
{
|
||||
$userIds = [];
|
||||
foreach ($users as $user) {
|
||||
$userId = (int)($user['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
$userIds[$userId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($userIds === []) {
|
||||
return $users;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$rows = $db->fetch_all($db->query(
|
||||
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` IN (' .
|
||||
implode(',', array_map('intval', array_keys($userIds))) .
|
||||
')'
|
||||
));
|
||||
|
||||
$managedUserIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$managedUserIds[(int)$row['user_id']] = true;
|
||||
}
|
||||
|
||||
foreach ($users as $key => $user) {
|
||||
$users[$key]['limited_backoffice_managed'] = isset($managedUserIds[(int)($user['id'] ?? 0)]);
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function isLimitedBackofficeManagedUser(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$userId . ' LIMIT 1'
|
||||
);
|
||||
|
||||
return $result !== false && $result->num_rows > 0;
|
||||
}
|
||||
|
||||
public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
|
||||
{
|
||||
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) {
|
||||
if ((bool)($value['limited_backoffice_managed'] ?? false) || (int)($value['customer_number'] ?? -1) === 0) {
|
||||
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $value['display_name'] ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
|
||||
}
|
||||
return $listObjectsWithPaginationIfSet;
|
||||
@@ -1264,16 +1183,15 @@ class users_o extends db
|
||||
* @param int $object_id The ID of the object
|
||||
* @param int $discount_percentage The discount percentage
|
||||
* @param bool $is_category If the object is a category
|
||||
* @param int|null $fixed_price The fixed product price, when set
|
||||
* @return void
|
||||
*/
|
||||
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false, ?int $fixed_price = null): void
|
||||
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false): void
|
||||
{
|
||||
$this->id = $user_id;
|
||||
// Get the user object properties
|
||||
$this->getObjectProperties();
|
||||
// Set the custom price (key = 'custom_price')
|
||||
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage, $fixed_price);
|
||||
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage);
|
||||
}
|
||||
|
||||
public function syncAllUsersEconomicCustomerDetails(): void
|
||||
|
||||
@@ -2595,12 +2595,6 @@ paths:
|
||||
type: string
|
||||
description: Contact person name
|
||||
example: "Mikkel"
|
||||
ean:
|
||||
type: string
|
||||
description: Optional EAN used for e-invoicing in e-conomic
|
||||
maxLength: 13
|
||||
pattern: '^[0-9]{1,13}$'
|
||||
example: "5790001234567"
|
||||
g_recaptcha_response:
|
||||
type: string
|
||||
description: reCAPTCHA verification token
|
||||
@@ -3098,7 +3092,7 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Users
|
||||
summary: Get user discounts and product fixed prices
|
||||
summary: Get user discounts
|
||||
operationId: getUserDiscounts
|
||||
parameters:
|
||||
- name: user_id
|
||||
@@ -3114,7 +3108,7 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Users
|
||||
summary: Set user discount or product fixed price
|
||||
summary: Set user discount
|
||||
operationId: setUserDiscount
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -3128,11 +3122,6 @@ paths:
|
||||
discount: {type: integer}
|
||||
object_id: {type: string}
|
||||
is_category: {type: boolean}
|
||||
fixed_price:
|
||||
type: integer
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: Optional product-only fixed price. Omit to preserve the current fixed price, send null to clear it.
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
@@ -8664,12 +8653,6 @@ paths:
|
||||
email: {type: string}
|
||||
phone: {type: integer}
|
||||
name: {type: string}
|
||||
ean:
|
||||
type: string
|
||||
description: Optional EAN used for e-invoicing in e-conomic
|
||||
maxLength: 13
|
||||
pattern: '^[0-9]{1,13}$'
|
||||
example: "5790001234567"
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
@@ -12540,40 +12523,6 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/superuser/departments/{id}/overview:
|
||||
get:
|
||||
tags:
|
||||
- Departments
|
||||
summary: Get superuser department overview
|
||||
description: Returns the selected department metadata and operational overview metrics for a superuser without requiring scoped department access.
|
||||
operationId: getSuperuserDepartmentOverview
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
- name: date
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string}
|
||||
- name: date_to
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
responses:
|
||||
'200':
|
||||
description: Superuser department overview loaded successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserDepartmentOverviewResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/superuser/department/branding:
|
||||
put:
|
||||
tags:
|
||||
@@ -13439,6 +13388,7 @@ components:
|
||||
- expected
|
||||
- actual
|
||||
- data_collection_accepted
|
||||
- screenshot
|
||||
properties:
|
||||
before_error:
|
||||
type: string
|
||||
@@ -13454,11 +13404,10 @@ components:
|
||||
description: What actually happened
|
||||
data_collection_accepted:
|
||||
type: boolean
|
||||
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
|
||||
description: Required acceptance of collecting screenshot and diagnostic error data
|
||||
screenshot:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
|
||||
description: PNG, JPEG, or WebP data URI of the current app viewport
|
||||
route_path:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -13569,7 +13518,6 @@ components:
|
||||
nullable: true
|
||||
screenshot:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
answers:
|
||||
type: object
|
||||
@@ -21602,21 +21550,6 @@ components:
|
||||
data:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
|
||||
|
||||
SuperuserDepartmentOverviewPayload:
|
||||
type: object
|
||||
properties:
|
||||
department:
|
||||
$ref: '#/components/schemas/Department'
|
||||
overview:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
|
||||
|
||||
SuperuserDepartmentOverviewResponse:
|
||||
type: object
|
||||
properties:
|
||||
success: { type: boolean, example: true }
|
||||
data:
|
||||
$ref: '#/components/schemas/SuperuserDepartmentOverviewPayload'
|
||||
|
||||
DepartmentDailyReportTransactionCountPayload:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1730,16 +1730,12 @@ class InvoicingPeriodRoute
|
||||
$product_cache[$product_id] = (new products_o())->select($product_id);
|
||||
}
|
||||
if (!isset($department_price_cache[$department_id][$product_id])) {
|
||||
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
|
||||
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
|
||||
}
|
||||
if (!array_key_exists($product_id, $discount_cache)) {
|
||||
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
|
||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
|
||||
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false);
|
||||
}
|
||||
$discount_cache[$product_id] = $unit_price;
|
||||
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
|
||||
}
|
||||
$post_discount = (int)$discount_cache[$product_id] * $quantity;
|
||||
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
|
||||
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,6 @@ class authRoute
|
||||
$contactEmail = self::getParameter('contactEmail');
|
||||
$contactPhone = (int)self::getParameter('contactPhone');
|
||||
$contactName = self::getParameter('contactName');
|
||||
$ean = null;
|
||||
/**
|
||||
* Validate
|
||||
*/
|
||||
@@ -455,13 +454,6 @@ class authRoute
|
||||
self::requireMinValue($contactPhone, 10000000);
|
||||
self::requireMaxValue($contactPhone, 9999999999);
|
||||
}
|
||||
if (self::isParametersSet(['ean'])) {
|
||||
try {
|
||||
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the contact phone is empty, default to company phone
|
||||
@@ -553,7 +545,6 @@ class authRoute
|
||||
(int)$companyPhone,
|
||||
(int)$contactPhone,
|
||||
$companyInformation,
|
||||
$ean,
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
|
||||
|
||||
@@ -812,53 +812,6 @@ class departmentDailyReportsRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/superuser/departments/{id}/overview', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_fetch_department');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$department_id_param = (string)($this->fromRoute('id') ?? '');
|
||||
if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) {
|
||||
$response->error('Parameter id must be a positive integer', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
self::requireParameters([
|
||||
'date',
|
||||
]);
|
||||
|
||||
self::validateDateLocally();
|
||||
$date_to = $this->getDate_to();
|
||||
$department_id = (int)$department_id_param;
|
||||
$department = (new departments_o())->select($department_id);
|
||||
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview');
|
||||
|
||||
$response->success([
|
||||
'department' => $department->asArray(['slack_webhook' => false]),
|
||||
'overview' => $this->buildDailyReportOverview(
|
||||
[$department_id],
|
||||
(string)self::getParameter('date'),
|
||||
$date_to
|
||||
),
|
||||
]);
|
||||
},
|
||||
[
|
||||
'superuser_fetch_department' => 'Get the superuser department overview'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/overview', function () {
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_reports');
|
||||
|
||||
@@ -103,7 +103,6 @@ class departmentsRoute
|
||||
'economic_department_id',
|
||||
'visible',
|
||||
'archived',
|
||||
'custom_pricing_only',
|
||||
'longitude',
|
||||
'latitude',
|
||||
])
|
||||
@@ -124,12 +123,6 @@ class departmentsRoute
|
||||
'latitude' => (float)$department['latitude'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
];
|
||||
if (
|
||||
$user->hasPermission('superuser_fetch_department')
|
||||
|| $user->hasPermission('edit_department')
|
||||
) {
|
||||
$tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
|
||||
}
|
||||
// If the user has the permission to view the slack webhook, add it to the response
|
||||
if ($user->hasPermission('view_slack_webhook')) {
|
||||
$tmp_department['slack_webhook'] = $department['slack_webhook'];
|
||||
@@ -227,9 +220,6 @@ class departmentsRoute
|
||||
if (self::isParametersSet(['archived'])) {
|
||||
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
|
||||
}
|
||||
if (self::isParametersSet(['custom_pricing_only'])) {
|
||||
$department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only')));
|
||||
}
|
||||
$department->objectChanged();
|
||||
// Log the incident
|
||||
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
|
||||
@@ -250,17 +240,11 @@ class departmentsRoute
|
||||
$this->get('/departments/categories', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$auth = new authentication();
|
||||
$user = $auth->get_user();
|
||||
$subuser = $auth->get_subuser();
|
||||
self::requirePermission('list_department_categories');
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
// Check if the request was successful
|
||||
if ($user || $subuser) {
|
||||
$isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser;
|
||||
if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) {
|
||||
$this->emitForbidden(['list_department_categories']);
|
||||
}
|
||||
|
||||
$responsibleUserId = $user ? (int)$user->id : 0;
|
||||
if ($user) {
|
||||
// Require the department id
|
||||
self::requireParameters(['id']);
|
||||
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
|
||||
@@ -269,14 +253,14 @@ class departmentsRoute
|
||||
// Validate the department categories object
|
||||
if (!$department->exists()) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
||||
// Return an error
|
||||
$response->error('Department categories not found', 400);
|
||||
}
|
||||
// Get the department categories
|
||||
$department_categories = new department_categories_o();
|
||||
// Log the incident
|
||||
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
||||
// Return the list of department categories
|
||||
$response->success(
|
||||
$department_categories
|
||||
@@ -301,7 +285,7 @@ class departmentsRoute
|
||||
}
|
||||
},
|
||||
[
|
||||
'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.'
|
||||
'list_department_categories' => 'List all department categories'
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -45,10 +45,10 @@ class limitedBackofficeRoute
|
||||
]);
|
||||
|
||||
$this->get('/limited-backoffice/roles', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->rolePresets($user);
|
||||
return $service->rolePresets();
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
@@ -78,17 +78,6 @@ class limitedBackofficeRoute
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->put('/limited-backoffice/employees/{employeeId}', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
|
||||
@@ -60,14 +60,6 @@ class moduleEconomicCustomerRoute
|
||||
self::requireMaxLength('phone', 255);
|
||||
self::requireMinLength('name', 1);
|
||||
self::requireMaxLength('name', 255);
|
||||
$ean = null;
|
||||
if (self::isParametersSet(['ean'])) {
|
||||
try {
|
||||
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
(new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer');
|
||||
$result = (new economic())->createCustomer(
|
||||
(int)self::getParameter('customer_number'),
|
||||
@@ -75,9 +67,6 @@ class moduleEconomicCustomerRoute
|
||||
(int)self::getParameter('cvr'),
|
||||
(string)self::getParameter('email'),
|
||||
(int)self::getParameter('phone'),
|
||||
null,
|
||||
null,
|
||||
$ean,
|
||||
);
|
||||
$response->success((object)$result);
|
||||
} else {
|
||||
@@ -87,4 +76,4 @@ class moduleEconomicCustomerRoute
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,18 @@ class orderBookingRoute
|
||||
$po = self::getTargetPo(); // String | Null
|
||||
$pickup = self::getTargetPickup(); // Bool | Null
|
||||
$items = self::getTargetItems(); // Array of order_items_o objects
|
||||
$this->requireOrderBookingCreateAccess(
|
||||
/**
|
||||
* Permissions (clean helper)
|
||||
*/
|
||||
$permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
|
||||
$permission_other = self::definePermission('add_bookings');
|
||||
self::allowOwnOrDepartmentAccess(
|
||||
$permission_own,
|
||||
$permission_other,
|
||||
(int)$customer_number->customer_number->value(),
|
||||
(int)$department->id
|
||||
(int)$department->id,
|
||||
null,
|
||||
'You do not have permission to create this order booking.'
|
||||
);
|
||||
/**
|
||||
* Input data
|
||||
@@ -87,8 +96,8 @@ class orderBookingRoute
|
||||
$response->success($order_bookings_o->asArray());
|
||||
},
|
||||
[
|
||||
'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.'
|
||||
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
|
||||
'add_bookings' => 'Permission to create department order bookings.'
|
||||
]
|
||||
);
|
||||
|
||||
@@ -650,48 +659,6 @@ class orderBookingRoute
|
||||
return $object;
|
||||
}
|
||||
|
||||
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
|
||||
{
|
||||
$auth = new authentication();
|
||||
|
||||
if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) {
|
||||
$permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
|
||||
if (!self::hasPermission($permissionOwn, $targetCustomerNumber)) {
|
||||
$this->emitForbidden([$permissionOwn]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
$auth->get_user() !== false
|
||||
&& self::hasPermission('user')
|
||||
&& $this->isOwnCustomerContext($targetCustomerNumber)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissionOther = self::definePermission('add_bookings');
|
||||
if (!self::hasPermission($permissionOther)) {
|
||||
$this->emitForbidden([$permissionOther]);
|
||||
}
|
||||
|
||||
self::requireDepartmentAccess((string)$departmentId);
|
||||
}
|
||||
|
||||
private function isOrderBookingCustomerSession(): bool
|
||||
{
|
||||
try {
|
||||
$auth = new authentication();
|
||||
if ($auth->get_subuser() !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $auth->get_user() !== false && self::hasPermission('user');
|
||||
} catch (Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception If the Department is invalid.
|
||||
*/
|
||||
|
||||
@@ -612,46 +612,21 @@ class orderInvoicesRoute
|
||||
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
|
||||
);
|
||||
|
||||
$date_from = $db->escape_string($date_range['dateFrom']);
|
||||
$date_to = $db->escape_string($date_range['dateTo']);
|
||||
$sql = "SELECT DISTINCT invoice_collection_id
|
||||
FROM orders
|
||||
WHERE created_at BETWEEN '$date_from' AND '$date_to'
|
||||
AND invoice_collection_id IS NOT NULL
|
||||
AND invoice_collection_id > 0
|
||||
AND deleted_at IS NULL";
|
||||
$query_result = $db->query($sql);
|
||||
$invoice_collection_ids = [];
|
||||
if (self::isParametersSet(['invoice_collection_ids'])) {
|
||||
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
|
||||
if (!is_array($invoice_collection_ids_raw)) {
|
||||
$response->error('invoice_collection_ids must be an array', 400);
|
||||
}
|
||||
|
||||
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
|
||||
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
|
||||
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||
}
|
||||
|
||||
$invoice_collection_id = (int)$invoice_collection_id_raw;
|
||||
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
|
||||
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||
}
|
||||
|
||||
while ($row = $query_result->fetch_assoc()) {
|
||||
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
||||
if ($invoice_collection_id > 0) {
|
||||
$invoice_collection_ids[] = $invoice_collection_id;
|
||||
}
|
||||
|
||||
$invoice_collection_ids = array_values(array_unique($invoice_collection_ids));
|
||||
if (empty($invoice_collection_ids)) {
|
||||
$response->error('invoice_collection_ids must contain at least one id', 400);
|
||||
}
|
||||
} else {
|
||||
$date_from = $db->escape_string($date_range['dateFrom']);
|
||||
$date_to = $db->escape_string($date_range['dateTo']);
|
||||
$sql = "SELECT DISTINCT invoice_collection_id
|
||||
FROM orders
|
||||
WHERE created_at BETWEEN '$date_from' AND '$date_to'
|
||||
AND invoice_collection_id IS NOT NULL
|
||||
AND invoice_collection_id > 0
|
||||
AND deleted_at IS NULL";
|
||||
$query_result = $db->query($sql);
|
||||
while ($row = $query_result->fetch_assoc()) {
|
||||
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
||||
if ($invoice_collection_id > 0) {
|
||||
$invoice_collection_ids[] = $invoice_collection_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$items = [];
|
||||
|
||||
@@ -205,7 +205,7 @@ class orderItemsRoute
|
||||
|
||||
$this->put('/order/items', function () {
|
||||
// Require the user to be logged in
|
||||
global $response, $db;
|
||||
global $response;
|
||||
$this->requirePermission('edit_order_items');
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -230,34 +230,16 @@ class orderItemsRoute
|
||||
$response->error('Quantity is required', 400);
|
||||
}
|
||||
|
||||
$orderItemId = (int)$data['id'];
|
||||
$orderItem = (new order_items_o())->getOrderItemById($orderItemId);
|
||||
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
|
||||
if (!$orderItem->exists()) {
|
||||
$response->error('Order item not found', 404);
|
||||
}
|
||||
$orderItemContextResult = $db->query(
|
||||
"SELECT oi.order_id, oi.product_id, p.name AS product_name, p.requires_note AS product_requires_note
|
||||
FROM order_items oi
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.id = {$orderItemId}
|
||||
LIMIT 1"
|
||||
);
|
||||
$orderItemContext = $orderItemContextResult ? $orderItemContextResult->fetch_assoc() : null;
|
||||
if ($orderItemContext === null) {
|
||||
$response->error('Order item not found', 404);
|
||||
}
|
||||
if ($orderItemContext['product_id'] === null || $orderItemContext['product_name'] === null) {
|
||||
$response->error('Product not found', 404);
|
||||
}
|
||||
if (products_o::productDataRequiresOrderItemNote([
|
||||
'id' => (int)$orderItemContext['product_id'],
|
||||
'name' => (string)$orderItemContext['product_name'],
|
||||
'requires_note' => (bool)$orderItemContext['product_requires_note'],
|
||||
]) && trim((string)$data['notes']) === '') {
|
||||
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
|
||||
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
|
||||
$order = (new orders_o())->getOrderById((int)$orderItemContext['order_id']);
|
||||
$order = (new orders_o())->getOrderById((int)$orderItem->order_id->value());
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 404);
|
||||
}
|
||||
|
||||
@@ -22,23 +22,21 @@ class productsRoute
|
||||
*/
|
||||
private function getCustomerIfProvided(): ?users_o
|
||||
{
|
||||
$customerId = $this->getOptionalPositiveIntParameter('customer_id');
|
||||
if ($customerId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
|
||||
if ($customerObject->exists()) {
|
||||
return $customerObject;
|
||||
global $response;
|
||||
if (self::isParametersSet(['customer_id'])) {
|
||||
$customerId = (int)self::getParameter('customer_id');
|
||||
try {
|
||||
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
|
||||
if ($customerObject->exists()) {
|
||||
return $customerObject;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||
// Return null
|
||||
return null;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -47,62 +45,12 @@ class productsRoute
|
||||
* @return int|null
|
||||
*/
|
||||
private function getDepartmentIdIfProvided(): ?int
|
||||
{
|
||||
return $this->getOptionalPositiveIntParameter('department_id');
|
||||
}
|
||||
|
||||
private function getOptionalPositiveIntParameter(string $parameter): ?int
|
||||
{
|
||||
global $response;
|
||||
if (!self::isParametersSet([$parameter])) {
|
||||
return null;
|
||||
if (self::isParametersSet(['department_id'])) {
|
||||
return (int)self::getParameter('department_id');
|
||||
}
|
||||
|
||||
$value = self::getParameter($parameter);
|
||||
if ($this->isNullLikeOptionalParameter($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = null;
|
||||
if (is_int($value)) {
|
||||
$parsed = $value;
|
||||
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
|
||||
$parsed = (int)trim($value);
|
||||
} else {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
if ($parsed === null || $parsed <= 0) {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isNullLikeOptionalParameter(mixed $value): bool
|
||||
{
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
|
||||
}
|
||||
|
||||
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
|
||||
{
|
||||
if (!$user instanceof users_o || $departmentId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasPermission('superuser_fetch_department')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requirePermission('department_access_' . $departmentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +59,11 @@ class productsRoute
|
||||
*/
|
||||
private function getCategoryIfProvided(): ?int
|
||||
{
|
||||
return $this->getOptionalPositiveIntParameter('category');
|
||||
global $response;
|
||||
if (self::isParametersSet(['category'])) {
|
||||
return (int)self::getParameter('category');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +72,11 @@ class productsRoute
|
||||
*/
|
||||
private function getProductIdIfProvided(): ?int
|
||||
{
|
||||
return $this->getOptionalPositiveIntParameter('id');
|
||||
global $response;
|
||||
if (self::isParametersSet(['id'])) {
|
||||
return (int)self::getParameter('id');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,14 +91,12 @@ class productsRoute
|
||||
// Check if the departmentId is set
|
||||
if ($departmentId) {
|
||||
// Apply the departments unique pricing
|
||||
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
|
||||
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
|
||||
}
|
||||
// Check if the customer is set
|
||||
if ($customer !== null) {
|
||||
// Apply the customers unique discounts
|
||||
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
|
||||
} else {
|
||||
$products = products_o::stripDepartmentPriceSources($products);
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
@@ -241,14 +195,12 @@ class productsRoute
|
||||
// Check if the request was successful
|
||||
if ($user || $isProductDetailsRestricted) {
|
||||
// Define the variables
|
||||
$customer = $this->getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
||||
$departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||
$category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||
$productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
|
||||
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
||||
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||
// Check if the "final_price" parameter is set, and true.
|
||||
if ($useFinalPrice) {
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
|
||||
// Determine the products to return
|
||||
if ($category) {
|
||||
// Get products in the category
|
||||
@@ -306,17 +258,17 @@ class productsRoute
|
||||
);
|
||||
}
|
||||
// Check if the category is set in the request
|
||||
$data = $_GET ?? [];
|
||||
// Check if the category is set
|
||||
if ($category !== null) {
|
||||
if (isset($data['category'])) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
|
||||
// Return the list of products
|
||||
$products = (new products_o())->listObjectsByCategory($category);
|
||||
$products = (new products_o())->listObjectsByCategory($data['category']);
|
||||
// Check if the department_id is set
|
||||
if ($departmentId !== null) {
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
if (isset($data['department_id'])) {
|
||||
// Apply the departments unique pricing
|
||||
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
|
||||
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
|
||||
}
|
||||
$response->success(
|
||||
array_map(function ($product) use ($isProductDetailsRestricted) {
|
||||
@@ -327,10 +279,9 @@ class productsRoute
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
|
||||
// Check if the department_id is set
|
||||
if ($departmentId !== null) {
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
if (isset($data['department_id'])) {
|
||||
// Get all product ids contained in a category attached to the department
|
||||
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
|
||||
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
|
||||
// Get the product ids as an array
|
||||
$departmentSpecificProductIds = array_map(function ($product) {
|
||||
return $product->id;
|
||||
@@ -345,7 +296,7 @@ class productsRoute
|
||||
(new products_o())->forceRestrictFilters([
|
||||
'id' => $departmentSpecificProductIds,
|
||||
])
|
||||
), $departmentId)
|
||||
), (int)$data['department_id'])
|
||||
);
|
||||
}
|
||||
// Return the list of products
|
||||
|
||||
@@ -103,9 +103,6 @@ class userRoute
|
||||
}
|
||||
// Check if the required fields are set
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($data)) {
|
||||
$response->error('Invalid request body', 400);
|
||||
}
|
||||
if (!isset($data['discount'])) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set');
|
||||
@@ -125,38 +122,14 @@ class userRoute
|
||||
$response->error('No is_category set', 400);
|
||||
}
|
||||
$discount = (int)$data['discount'];
|
||||
if ($discount < 0 || $discount > 100) {
|
||||
$response->error('Discount must be between 0 and 100', 400);
|
||||
}
|
||||
$is_category = (bool)$data['is_category'];
|
||||
if ($is_category) {
|
||||
$object_id = (string)$data['object_id'];
|
||||
} else {
|
||||
$object_id = (int)$data['object_id'];
|
||||
}
|
||||
$fixed_price_is_set = array_key_exists('fixed_price', $data);
|
||||
$fixed_price = null;
|
||||
if ($fixed_price_is_set) {
|
||||
if ($data['fixed_price'] === null || $data['fixed_price'] === '') {
|
||||
$fixed_price = null;
|
||||
} else {
|
||||
$fixed_price_value = filter_var($data['fixed_price'], FILTER_VALIDATE_INT);
|
||||
if ($fixed_price_value === false) {
|
||||
$response->error('Invalid fixed price', 400);
|
||||
}
|
||||
$fixed_price = (int)$fixed_price_value;
|
||||
}
|
||||
if ($fixed_price !== null && $fixed_price < 0) {
|
||||
$response->error('Fixed price must be zero or more', 400);
|
||||
}
|
||||
if ($is_category && $fixed_price !== null) {
|
||||
$response->error('Fixed price can only be set for products', 400);
|
||||
}
|
||||
} elseif (!$is_category) {
|
||||
$fixed_price = $targetUser->getProductFixedPrice((int)$object_id);
|
||||
}
|
||||
// Set the custom price
|
||||
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category, $fixed_price);
|
||||
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category);
|
||||
try {
|
||||
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
|
||||
(int)$targetUser->id,
|
||||
@@ -172,8 +145,7 @@ class userRoute
|
||||
'route' => '/superuser/user/discounts',
|
||||
'method' => 'POST',
|
||||
'actor_user_id' => (int)$user->id,
|
||||
],
|
||||
$fixed_price
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
(new logs_o())->add(
|
||||
|
||||
@@ -27,28 +27,19 @@ class usersRoute
|
||||
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
|
||||
// Return the list of users
|
||||
$users_o = new users_o();
|
||||
$limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
|
||||
$users = $users_o
|
||||
->setSearchableFields([
|
||||
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
|
||||
'id',
|
||||
'customer_number',
|
||||
'group_id',
|
||||
'display_name',
|
||||
])
|
||||
->listObjectsWithPaginationIfSet(
|
||||
null,
|
||||
$limitedEmployeeListMode['filters'],
|
||||
[],
|
||||
$limitedEmployeeListMode['additional_where']
|
||||
);
|
||||
if ($limitedEmployeeListMode['enabled']) {
|
||||
$users = $users_o->markLimitedBackofficeManagedUsers($users);
|
||||
}
|
||||
$users = $users_o->parseUsers(
|
||||
$users
|
||||
$response->success(
|
||||
$users_o->parseUsers(
|
||||
$users_o
|
||||
->setSearchableFields([
|
||||
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
|
||||
'id',
|
||||
'customer_number',
|
||||
'group_id',
|
||||
'display_name',
|
||||
])
|
||||
->listObjectsWithPaginationIfSet()
|
||||
)
|
||||
);
|
||||
$response->success($users);
|
||||
} else {
|
||||
// Log the incident
|
||||
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session');
|
||||
@@ -162,23 +153,6 @@ class usersRoute
|
||||
if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
|
||||
$data['display_name'] = null;
|
||||
}
|
||||
$targetUser = (new users_o())->getUserById((int)$data['id']);
|
||||
if (!$targetUser->exists()) {
|
||||
$response->error('User not found', 404);
|
||||
}
|
||||
|
||||
if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) {
|
||||
$currentCustomerNumber = (string)$targetUser->customer_number->value();
|
||||
if ((string)$data['customer_number'] !== $currentCustomerNumber) {
|
||||
$response->error('Limited backoffice managed users cannot change customer number.', 403);
|
||||
}
|
||||
|
||||
if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) {
|
||||
$response->error('Limited backoffice managed users cannot change role.', 403);
|
||||
}
|
||||
|
||||
$data['role'] = null;
|
||||
}
|
||||
// If the role is set, require the edit_user_role permission
|
||||
if ($data['role']) {
|
||||
$this->requirePermission('edit_user_role');
|
||||
@@ -233,44 +207,4 @@ class usersRoute
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
|
||||
*/
|
||||
private function limitedBackofficeEmployeeListMode(users_o $users): array
|
||||
{
|
||||
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
|
||||
$filters = $this->fromQuery('filters');
|
||||
|
||||
if (!$enabled || $filters === null || $filters === '') {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'filters' => null,
|
||||
'additional_where' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$filterArray = $users->filter_string_to_array($filters);
|
||||
$customerNumberFilter = $filterArray['customer_number'] ?? null;
|
||||
$isEmployeeFilter = $customerNumberFilter === '0'
|
||||
|| $customerNumberFilter === 0
|
||||
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
|
||||
|
||||
if (!$isEmployeeFilter) {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'filters' => $filters,
|
||||
'additional_where' => null,
|
||||
];
|
||||
}
|
||||
|
||||
unset($filterArray['customer_number']);
|
||||
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
|
||||
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@ CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` (
|
||||
`is_category` TINYINT(1) NOT NULL,
|
||||
`object_id` VARCHAR(64) NOT NULL,
|
||||
`discount` INT NOT NULL,
|
||||
`fixed_price` INT NULL DEFAULT NULL,
|
||||
`effective_from` DATETIME NOT NULL,
|
||||
`effective_to` DATETIME NULL,
|
||||
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
|
||||
|
||||
@@ -85,65 +85,6 @@ it('previews monthly split changes without moving orders or creating collections
|
||||
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
|
||||
});
|
||||
|
||||
it('previews only explicit monthly split invoice collection ids', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'preview-scope');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Preview Monthly Split Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
$ignoredCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
$targetMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-03-15 10:00:00',
|
||||
]);
|
||||
$targetAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-04-02 10:00:00',
|
||||
]);
|
||||
$ignoredMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-03-16 10:00:00',
|
||||
]);
|
||||
$ignoredAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-04-03 10:00:00',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||
|
||||
$response = api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => [$targetCollection['id']],
|
||||
'preview' => true,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $response->data();
|
||||
expect($payload['processed_count'] ?? null)->toBe(1)
|
||||
->and($payload['changed_count'] ?? null)->toBe(1)
|
||||
->and($payload['changed'][0]['invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
|
||||
});
|
||||
|
||||
it('splits a selected March and April collected invoice into monthly collections', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
|
||||
|
||||
@@ -198,74 +139,6 @@ it('splits a selected March and April collected invoice into monthly collections
|
||||
}
|
||||
});
|
||||
|
||||
it('splits only explicit monthly split invoice collection ids', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'scope');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'created_at' => '2096-03-01 00:00:01',
|
||||
]);
|
||||
$ignoredCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'created_at' => '2096-03-01 00:00:01',
|
||||
]);
|
||||
$targetMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-03-15 10:00:00',
|
||||
]);
|
||||
$targetAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-04-02 10:00:00',
|
||||
]);
|
||||
$ignoredMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-03-16 10:00:00',
|
||||
]);
|
||||
$ignoredAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-04-03 10:00:00',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||
$createdCollectionIds = [];
|
||||
|
||||
try {
|
||||
$response = api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => [$targetCollection['id']],
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $response->data();
|
||||
$createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []);
|
||||
$aprilCollectionId = (int)($createdCollectionIds[0] ?? 0);
|
||||
|
||||
expect($payload['processed_count'] ?? null)->toBe(1)
|
||||
->and($payload['changed_count'] ?? null)->toBe(1)
|
||||
->and($aprilCollectionId)->toBeGreaterThan(0)
|
||||
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId)
|
||||
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
|
||||
} finally {
|
||||
monthly_split_cleanup_collections($createdCollectionIds);
|
||||
}
|
||||
});
|
||||
|
||||
it('sets closed_at to month end when split month has ended', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
|
||||
|
||||
@@ -472,27 +345,3 @@ it('rejects invalid monthly split date ranges', function (): void {
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
});
|
||||
|
||||
it('rejects invalid explicit monthly split invoice collection ids', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'invalid-scope');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||
|
||||
api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => ['not-a-number'],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => [],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
});
|
||||
|
||||
@@ -231,7 +231,6 @@ it('updates departments through the real endpoint', function (): void {
|
||||
'description' => 'Updated description',
|
||||
'order_priority' => 5,
|
||||
'archived' => true,
|
||||
'custom_pricing_only' => true,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
@@ -247,7 +246,6 @@ it('updates departments through the real endpoint', function (): void {
|
||||
expect($row['description'] ?? null)->toBe('Updated description');
|
||||
expect((int)($row['order_priority'] ?? 0))->toBe(5);
|
||||
expect((int)($row['archived'] ?? 0))->toBe(1);
|
||||
expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid department update requests', function (): void {
|
||||
@@ -301,42 +299,6 @@ it('lists department categories for a department', function (): void {
|
||||
->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||
});
|
||||
|
||||
it('lets customer booking sessions list department categories without the management permission', function (): void {
|
||||
api_test_covers('GET /departments/categories', 'auth');
|
||||
|
||||
$customerSession = api_fixtures()->createUserSession(['user']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$category = api_fixtures()->createCategory([
|
||||
'name' => 'Customer Department Category',
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
|
||||
$customerResponse = api_client()->get('/departments/categories?id=' . $department['id'], $customerSession['headers']);
|
||||
|
||||
$customerResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($customerResponse->data())
|
||||
->toBeArray()
|
||||
->toHaveCount(1)
|
||||
->and($customerResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||
|
||||
$subuserSession = api_fixtures()->createSubuserSession((int)$customerSession['user']['customer_number'], []);
|
||||
$subuserResponse = api_client()->get('/departments/categories?id=' . $department['id'], $subuserSession['headers']);
|
||||
|
||||
$subuserResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($subuserResponse->data())
|
||||
->toBeArray()
|
||||
->toHaveCount(1)
|
||||
->and($subuserResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||
});
|
||||
|
||||
it('rejects invalid department category requests', function (): void {
|
||||
api_test_covers('GET /departments/categories', 'failure');
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function error_report_api_payload(array $overrides = []): array
|
||||
{
|
||||
return array_replace_recursive([
|
||||
'before_error' => 'Opening the orders page',
|
||||
'expected' => 'The orders should load',
|
||||
'actual' => 'The page showed an error',
|
||||
'data_collection_accepted' => true,
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
'route_path' => '/admin/orders',
|
||||
'page_url' => 'https://app.example.test/admin/orders',
|
||||
'release_trace_id' => 'trace-error-report-test',
|
||||
'frontend_version' => 'frontend-test',
|
||||
'api_version' => 'api-test',
|
||||
'request_errors' => [
|
||||
['method' => 'GET', 'url' => '/orders', 'statusCode' => 500],
|
||||
],
|
||||
'vue_errors' => [
|
||||
['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']],
|
||||
],
|
||||
'context' => [
|
||||
'viewport' => ['width' => 1280, 'height' => 720],
|
||||
'user_agent' => 'ErrorReportsApiTest',
|
||||
'captured_at' => '2026-07-06T10:00:00.000Z',
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function error_report_api_cleanup(array $report): void
|
||||
{
|
||||
$id = (int)($report['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
api_fixtures()->cleanupDeleteById('error_reports', $id);
|
||||
}
|
||||
}
|
||||
|
||||
it('creates error reports when screenshot capture failed', function (): void {
|
||||
api_test_covers('POST /error-reports', 'happy');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => null,
|
||||
'context' => [
|
||||
'screenshot_attachment' => ['status' => 'capture_failed'],
|
||||
],
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['answers']['before_error'])->toBe('Opening the orders page');
|
||||
expect($report['request_error_count'])->toBe(1);
|
||||
expect($report['vue_error_count'])->toBe(1);
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'capture_failed',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
|
||||
it('creates error reports when an optional screenshot payload is invalid', function (): void {
|
||||
api_test_covers('POST /error-reports', 'invalid optional screenshot');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'),
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'invalid',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
@@ -20,34 +20,6 @@ function limited_backoffice_manager_session(array $departmentIds, array $extraPe
|
||||
return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions))));
|
||||
}
|
||||
|
||||
function limited_backoffice_all_role_permissions(): array
|
||||
{
|
||||
return [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_orders',
|
||||
'add_order',
|
||||
'edit_order',
|
||||
'delete_order',
|
||||
'list_order_items',
|
||||
'add_order_items',
|
||||
'edit_order_items',
|
||||
'delete_order_items',
|
||||
'charge_order',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'department_timebookings_entries_get',
|
||||
'department_timebookings_entries_post',
|
||||
'department_timebookings_entries_put',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
];
|
||||
}
|
||||
|
||||
function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
@@ -75,14 +47,6 @@ function limited_backoffice_price_value(int $departmentId, int $productId): ?int
|
||||
return $row === null ? null : (int)$row['price'];
|
||||
}
|
||||
|
||||
function limited_backoffice_price_rows(int $departmentId, int $productId): array
|
||||
{
|
||||
return api_test_runtime()->db()->query(
|
||||
'SELECT `id`, `price` FROM `product_department_prices` WHERE `department_id` = ' . $departmentId .
|
||||
' AND `product_id` = ' . $productId . ' ORDER BY `id` ASC'
|
||||
)->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
function limited_backoffice_cleanup_created_employee(int $employeeId): void
|
||||
{
|
||||
$row = api_test_runtime()->queryOne(
|
||||
@@ -208,63 +172,6 @@ it('lists and updates explicit prices only for assigned departments', function (
|
||||
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(2222);
|
||||
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$product['id']))->toBe(4321);
|
||||
expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(2222);
|
||||
});
|
||||
|
||||
it('returns saved prices and collapses legacy duplicate department price rows', function (): void {
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'legacy duplicates');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Legacy Duplicate Prices']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Legacy Duplicate Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Legacy Duplicate Price',
|
||||
'category' => $category['id'],
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
|
||||
$db = api_test_runtime()->db();
|
||||
$index = $db->query("SHOW INDEX FROM `product_department_prices` WHERE `Key_name` = 'uniq_product_department_prices_lookup'");
|
||||
if ($index === false) {
|
||||
throw new RuntimeException('Unable to inspect product_department_prices lookup index.');
|
||||
}
|
||||
$hadIndex = (int)$index->num_rows > 0;
|
||||
if ($hadIndex) {
|
||||
$db->query('ALTER TABLE `product_department_prices` DROP INDEX `uniq_product_department_prices_lookup`');
|
||||
}
|
||||
|
||||
try {
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 111);
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 222);
|
||||
expect(limited_backoffice_price_rows((int)$department['id'], (int)$product['id']))->toHaveCount(2);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$product['id'], 'price' => 333],
|
||||
],
|
||||
], $session['headers']);
|
||||
|
||||
$updated
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$rows = limited_backoffice_price_rows((int)$department['id'], (int)$product['id']);
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect((int)$rows[0]['price'])->toBe(333);
|
||||
expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(333);
|
||||
} finally {
|
||||
$db->query(
|
||||
'DELETE FROM `product_department_prices` WHERE `department_id` = ' . (int)$department['id'] .
|
||||
' AND `product_id` = ' . (int)$product['id']
|
||||
);
|
||||
if ($hadIndex) {
|
||||
$db->query(
|
||||
'ALTER TABLE `product_department_prices`
|
||||
ADD UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`)'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('updates department prices when the price table has no updated_at column', function (): void {
|
||||
@@ -304,68 +211,6 @@ it('updates department prices when the price table has no updated_at column', fu
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates products from duplicate department category links', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Products']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Category']);
|
||||
$firstProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Duplicate Price A',
|
||||
'category' => $category['id'],
|
||||
]);
|
||||
$secondProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Duplicate Price B',
|
||||
'category' => $category['id'],
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$firstProduct['id'], 111);
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$secondProduct['id'], 222);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$productIds = [];
|
||||
foreach ($response->data()['categories'] as $departmentCategory) {
|
||||
foreach ($departmentCategory['products'] as $departmentProduct) {
|
||||
$productIds[] = (int)$departmentProduct['id'];
|
||||
}
|
||||
}
|
||||
|
||||
expect($productIds)->toBe([(int)$firstProduct['id'], (int)$secondProduct['id']]);
|
||||
});
|
||||
|
||||
it('deduplicates missing product setup gaps from duplicate department category links', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe failure');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Setup Gap']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Setup Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Duplicate Missing Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 88888,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||
$response
|
||||
->assertStatus(409)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Department price setup is incomplete.');
|
||||
|
||||
expect(array_column($response->data()['missing_products'], 'id'))->toBe([(int)$product['id']]);
|
||||
expect($response->body)->not->toContain('88888');
|
||||
});
|
||||
|
||||
it('rejects cross-department price access, body spoofing, and outside products', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||
@@ -440,85 +285,6 @@ it('fails price setup gaps without exposing product defaults', function (): void
|
||||
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
|
||||
});
|
||||
|
||||
it('defaults missing custom-only department prices to sentinel without exposing fallback prices', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments', 'happy');
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Limited Custom Pricing Only',
|
||||
'custom_pricing_only' => 1,
|
||||
]);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Other Pricing']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Custom Pricing Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Custom Missing Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 87654,
|
||||
]);
|
||||
$otherProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Custom Missing Other Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 76543,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321);
|
||||
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$otherProduct['id'], 5432);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$departments = api_client()->get('/limited-backoffice/departments', $session['headers']);
|
||||
$departments
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($departments->data()[0]['custom_pricing_only'] ?? null)->toBeTrue();
|
||||
|
||||
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->body)->not->toContain('87654');
|
||||
expect($response->body)->not->toContain('76543');
|
||||
expect($response->body)->not->toContain('4321');
|
||||
expect($response->body)->not->toContain('5432');
|
||||
expect($response->data()['department']['custom_pricing_only'] ?? null)->toBeTrue();
|
||||
$products = [];
|
||||
foreach ($response->data()['categories'] as $departmentCategory) {
|
||||
foreach ($departmentCategory['products'] as $departmentProduct) {
|
||||
$products[(int)$departmentProduct['id']] = $departmentProduct;
|
||||
}
|
||||
}
|
||||
expect($products[(int)$product['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
expect($products[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
|
||||
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$product['id'], 'price' => 2222],
|
||||
],
|
||||
], $session['headers']);
|
||||
$updated
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$updatedProducts = [];
|
||||
foreach ($updated->data()['categories'] as $departmentCategory) {
|
||||
foreach ($departmentCategory['products'] as $departmentProduct) {
|
||||
$updatedProducts[(int)$departmentProduct['id']] = $departmentProduct;
|
||||
}
|
||||
}
|
||||
expect($updated->body)->not->toContain('87654');
|
||||
expect($updated->body)->not->toContain('76543');
|
||||
expect($updated->body)->not->toContain('4321');
|
||||
expect($updated->body)->not->toContain('5432');
|
||||
expect($updatedProducts[(int)$product['id']]['price'] ?? null)->toBe(2222);
|
||||
expect($updatedProducts[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
});
|
||||
|
||||
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
|
||||
|
||||
@@ -547,21 +313,6 @@ it('rejects invalid price batches and leaves existing prices unchanged', functio
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200);
|
||||
}
|
||||
|
||||
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$firstProduct['id'], 'price' => 999],
|
||||
['product_id' => (int)$firstProduct['id'], 'price' => 888],
|
||||
['product_id' => (int)$secondProduct['id'], 'price' => 777],
|
||||
],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Duplicate product price rows are not allowed.');
|
||||
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200);
|
||||
|
||||
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$firstProduct['id'], 'price' => 999],
|
||||
@@ -588,7 +339,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
expect((int)$usersDeletedAtColumn->num_rows)->toBe(0);
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']], limited_backoffice_all_role_permissions());
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
|
||||
$roles
|
||||
@@ -597,60 +348,11 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
->assertSuccess();
|
||||
|
||||
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
|
||||
$rolesByKey = array_column($roles->data(), null, 'key');
|
||||
expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([
|
||||
[
|
||||
'key' => 'account',
|
||||
'capabilities' => ['sign_in', 'view_own_permissions'],
|
||||
],
|
||||
]);
|
||||
$departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key');
|
||||
expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([
|
||||
'open_limited_backoffice',
|
||||
'manage_department_prices',
|
||||
'manage_employee_access',
|
||||
]);
|
||||
expect($roles->body)->not->toContain('department_access_');
|
||||
$rolePayload = $roles->data();
|
||||
$rolePayloadStrings = [];
|
||||
array_walk_recursive($rolePayload, static function ($value) use (&$rolePayloadStrings): void {
|
||||
if (is_string($value)) {
|
||||
$rolePayloadStrings[] = $value;
|
||||
}
|
||||
});
|
||||
foreach ([
|
||||
'list_orders',
|
||||
'add_order',
|
||||
'edit_order',
|
||||
'delete_order',
|
||||
'list_order_items',
|
||||
'add_order_items',
|
||||
'edit_order_items',
|
||||
'delete_order_items',
|
||||
'charge_order',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'department_timebookings_entries_get',
|
||||
'department_timebookings_entries_post',
|
||||
'department_timebookings_entries_put',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
'limited_backoffice_access',
|
||||
'limited_backoffice_prices_manage',
|
||||
'limited_backoffice_employees_manage',
|
||||
] as $rawPermission) {
|
||||
expect($rolePayloadStrings)->not->toContain($rawPermission);
|
||||
}
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Cashier',
|
||||
'email' => 'limited-cashier@example.test',
|
||||
'phone_country_code' => 45,
|
||||
'phone' => 12345678,
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -664,10 +366,6 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
expect($created->data()['user_id'] ?? null)->toBe($employeeId);
|
||||
expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test');
|
||||
expect($created->data()['phone_country_code'] ?? null)->toBe(45);
|
||||
expect($created->data()['phone'] ?? null)->toBe(12345678);
|
||||
expect($created->body)->not->toContain('department_access_');
|
||||
expect($created->body)->not->toContain('permissions');
|
||||
|
||||
@@ -681,19 +379,11 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
$permissions = array_column($permissionRows, 'permission');
|
||||
expect($permissions)
|
||||
->toContain('department_access_' . (int)$department['id'])
|
||||
->toContain('employee_public_data')
|
||||
->toContain('add_order')
|
||||
->not->toContain('superuser');
|
||||
|
||||
$publicEmployees = api_client()->get('/public/employees');
|
||||
$publicEmployeeIds = array_map('intval', array_column($publicEmployees->data(), 'id'));
|
||||
expect($publicEmployeeIds)->toContain($employeeId);
|
||||
|
||||
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'display_name' => 'Limited Lead',
|
||||
'email' => 'limited-lead@example.test',
|
||||
'phone_country_code' => 358,
|
||||
'phone' => 87654321,
|
||||
'role_key' => 'operations_lead',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
@@ -703,26 +393,11 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
->assertSuccess();
|
||||
|
||||
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
|
||||
expect($updated->data()['email'] ?? null)->toBe('limited-lead@example.test');
|
||||
expect($updated->data()['phone_country_code'] ?? null)->toBe(358);
|
||||
expect($updated->data()['phone'] ?? null)->toBe(87654321);
|
||||
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
|
||||
|
||||
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
||||
$ids = array_column($list->data(), 'id');
|
||||
expect($ids)->toContain($employeeId);
|
||||
$listedEmployee = null;
|
||||
foreach ($list->data() as $employee) {
|
||||
if ((int)($employee['id'] ?? 0) === $employeeId) {
|
||||
$listedEmployee = $employee;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect($listedEmployee)->not->toBeNull();
|
||||
expect($listedEmployee['user_id'] ?? null)->toBe($employeeId);
|
||||
expect($listedEmployee['email'] ?? null)->toBe('limited-lead@example.test');
|
||||
expect($listedEmployee['phone_country_code'] ?? null)->toBe(358);
|
||||
expect($listedEmployee['phone'] ?? null)->toBe(87654321);
|
||||
expect($list->body)->not->toContain('department_access_');
|
||||
|
||||
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
|
||||
@@ -737,9 +412,6 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
expect(array_key_exists('password', $userRow ?? []))->toBeTrue();
|
||||
expect($userRow['password'])->toBeNull();
|
||||
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
|
||||
$publicEmployeesAfterDeactivation = api_client()->get('/public/employees');
|
||||
$publicEmployeeIdsAfterDeactivation = array_map('intval', array_column($publicEmployeesAfterDeactivation->data(), 'id'));
|
||||
expect($publicEmployeeIdsAfterDeactivation)->not->toContain($employeeId);
|
||||
$employeeRow = api_test_runtime()->queryOne(
|
||||
'SELECT `deactivated_at` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
|
||||
);
|
||||
@@ -747,355 +419,6 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
});
|
||||
});
|
||||
|
||||
it('caps limited employee permissions to the manager permissions and selected departments', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'auth');
|
||||
api_test_covers('GET /limited-backoffice/roles', 'auth');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Permission Cap']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']], [
|
||||
'list_orders',
|
||||
]);
|
||||
|
||||
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
|
||||
$rolesByKey = array_column($roles->data(), null, 'key');
|
||||
$operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key');
|
||||
expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in']);
|
||||
expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']);
|
||||
expect($roles->body)->not->toContain('create_orders');
|
||||
expect($roles->body)->not->toContain('view_order_statistics');
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Capped Lead',
|
||||
'email' => 'limited-capped@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'operations_lead',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
$created
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
|
||||
$groupRow = api_test_runtime()->queryOne(
|
||||
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
|
||||
);
|
||||
$groupId = (int)($groupRow['managed_group_id'] ?? 0);
|
||||
$permissionRows = api_test_runtime()->db()->query(
|
||||
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $groupId
|
||||
)->fetch_all(MYSQLI_ASSOC);
|
||||
$permissions = array_column($permissionRows, 'permission');
|
||||
|
||||
expect($permissions)
|
||||
->toContain('user')
|
||||
->toContain('employee_public_data')
|
||||
->toContain('list_orders')
|
||||
->toContain('department_access_' . (int)$department['id'])
|
||||
->not->toContain('add_order')
|
||||
->not->toContain('delete_order')
|
||||
->not->toContain('statistics_orders_new')
|
||||
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
});
|
||||
|
||||
it('generates reusable QR login links for active scoped employees', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Department']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited QR Employee',
|
||||
'email' => 'limited-qr@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
$created
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/limited-backoffice/employees/' . $employeeId . '/login-link',
|
||||
[],
|
||||
$session['headers']
|
||||
);
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$loginPath = (string)($response->data()['login_path'] ?? '');
|
||||
expect($response->data()['employee_id'] ?? null)->toBe($employeeId);
|
||||
expect($loginPath)->toMatch('/^\/login\/qr\?token=[a-f0-9]{64}$/');
|
||||
|
||||
parse_str((string)parse_url($loginPath, PHP_URL_QUERY), $query);
|
||||
$token = (string)($query['token'] ?? '');
|
||||
expect($token)->toMatch('/^[a-f0-9]{64}$/');
|
||||
|
||||
$tokenRow = api_test_runtime()->queryOne(
|
||||
"SELECT `user_id`, `type` FROM `tokens` WHERE `token` = '" .
|
||||
api_test_runtime()->db()->real_escape_string($token) .
|
||||
"' LIMIT 1"
|
||||
);
|
||||
expect($tokenRow)->not->toBeNull();
|
||||
expect((int)($tokenRow['user_id'] ?? 0))->toBe($employeeId);
|
||||
expect($tokenRow['type'] ?? null)->toBe('AUTH_TOKEN');
|
||||
|
||||
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
||||
$list
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($list->body)->not->toContain($token);
|
||||
expect($list->body)->not->toContain('login_path');
|
||||
});
|
||||
|
||||
it('rejects invalid limited backoffice employee QR login link generation', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth');
|
||||
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Own']);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Login Link Other']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
$otherSession = limited_backoffice_manager_session([(int)$otherDepartment['id']]);
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Link Target',
|
||||
'email' => 'limited-link-target@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
|
||||
$withoutManageEmployees = api_fixtures()->createUserSession([
|
||||
limited_backoffice_service::PERMISSION_ACCESS,
|
||||
'department_access_' . (int)$department['id'],
|
||||
]);
|
||||
api_client()->post(
|
||||
'/limited-backoffice/employees/' . $employeeId . '/login-link',
|
||||
[],
|
||||
$withoutManageEmployees['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES]);
|
||||
|
||||
api_client()->post(
|
||||
'/limited-backoffice/employees/' . (int)$session['user']['id'] . '/login-link',
|
||||
[],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Managers cannot edit themselves.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees/999999999/login-link', [], $session['headers'])
|
||||
->assertStatus(404)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Managed employee not found.');
|
||||
|
||||
$otherCreated = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Other Department Target',
|
||||
'email' => 'limited-other-target@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$otherDepartment['id']],
|
||||
], $otherSession['headers']);
|
||||
$otherEmployeeId = (int)($otherCreated->data()['id'] ?? 0);
|
||||
expect($otherEmployeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($otherEmployeeId);
|
||||
|
||||
api_client()->post(
|
||||
'/limited-backoffice/employees/' . $otherEmployeeId . '/login-link',
|
||||
[],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||
|
||||
api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
api_client()->post('/limited-backoffice/employees/' . $employeeId . '/login-link', [], $session['headers'])
|
||||
->assertStatus(409)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Cannot create a login link for an inactive employee.');
|
||||
|
||||
$superuser = api_fixtures()->createUser(['group_id' => 1]);
|
||||
api_test_runtime()->db()->query(
|
||||
'INSERT INTO `limited_backoffice_employees`
|
||||
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
|
||||
VALUES (' . (int)$superuser['id'] . ", 1, 'department_admin', '[" . (int)$department['id'] . "]', " . (int)$session['user']['id'] . ')'
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$superuser['id']]);
|
||||
|
||||
api_client()->post('/limited-backoffice/employees/' . (int)$superuser['id'] . '/login-link', [], $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Cannot manage superuser accounts.');
|
||||
|
||||
$sharedGroup = api_fixtures()->createGroup();
|
||||
$firstSharedUser = api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
|
||||
api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
|
||||
$departmentJson = '[' . (int)$department['id'] . ']';
|
||||
api_test_runtime()->db()->query(
|
||||
'INSERT INTO `limited_backoffice_employees`
|
||||
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
|
||||
VALUES (' . (int)$firstSharedUser['id'] . ', ' . (int)$sharedGroup['id'] . ", 'viewer', '" . $departmentJson . "', " . (int)$session['user']['id'] . ')'
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$firstSharedUser['id']]);
|
||||
|
||||
api_client()->post('/limited-backoffice/employees/' . (int)$firstSharedUser['id'] . '/login-link', [], $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Cannot manage shared groups.');
|
||||
});
|
||||
|
||||
it('includes limited employees in the regular employee list and protects raw user edits', function (): void {
|
||||
api_test_covers('GET /users', 'limited backoffice employee list');
|
||||
api_test_covers('PUT /users', 'limited backoffice guard');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Regular List']);
|
||||
$managerSession = limited_backoffice_manager_session([(int)$department['id']], [
|
||||
'permissions_list_own',
|
||||
]);
|
||||
|
||||
$regularEmployee = api_fixtures()->createUser([
|
||||
'customer_number' => 0,
|
||||
'display_name' => 'Regular Backoffice Employee',
|
||||
], ['employee_public_data']);
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Listed Employee',
|
||||
'email' => 'limited-listed@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $managerSession['headers']);
|
||||
$created
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
|
||||
$adminSession = api_fixtures()->createUserSession([
|
||||
'list_users',
|
||||
'edit_user',
|
||||
]);
|
||||
|
||||
$withoutLimited = api_client()->get('/users?page=1&limit=50&filters=customer_number:0', $adminSession['headers']);
|
||||
$withoutLimitedIds = array_map('intval', array_column($withoutLimited->data(), 'id'));
|
||||
expect($withoutLimitedIds)->toContain((int)$regularEmployee['id']);
|
||||
expect($withoutLimitedIds)->not->toContain($employeeId);
|
||||
|
||||
$withLimited = api_client()->get(
|
||||
'/users?page=1&limit=50&filters=customer_number:0&include_limited_backoffice_employees=true',
|
||||
$adminSession['headers']
|
||||
);
|
||||
$usersById = array_column($withLimited->data(), null, 'id');
|
||||
expect(array_keys($usersById))->toContain((int)$regularEmployee['id']);
|
||||
expect(array_keys($usersById))->toContain($employeeId);
|
||||
expect($usersById[$employeeId]['limited_backoffice_managed'] ?? null)->toBeTrue();
|
||||
expect($usersById[(int)$regularEmployee['id']]['limited_backoffice_managed'] ?? null)->toBeFalse();
|
||||
|
||||
$userRow = api_test_runtime()->queryOne(
|
||||
'SELECT `customer_number`, `group_id` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1'
|
||||
);
|
||||
$customerNumber = (int)($userRow['customer_number'] ?? 0);
|
||||
$groupId = (int)($userRow['group_id'] ?? 0);
|
||||
|
||||
api_client()->put('/users', [
|
||||
'id' => $employeeId,
|
||||
'customer_number' => $customerNumber + 1,
|
||||
'role' => $groupId,
|
||||
'display_name' => 'Blocked Customer Change',
|
||||
], $adminSession['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Limited backoffice managed users cannot change customer number.');
|
||||
|
||||
api_client()->put('/users', [
|
||||
'id' => $employeeId,
|
||||
'customer_number' => $customerNumber,
|
||||
'role' => 0,
|
||||
'display_name' => 'Blocked Role Change',
|
||||
], $adminSession['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Limited backoffice managed users cannot change role.');
|
||||
|
||||
api_client()->put('/users', [
|
||||
'id' => $employeeId,
|
||||
'customer_number' => $customerNumber,
|
||||
'role' => $groupId,
|
||||
'display_name' => 'Edited Limited Listed Employee',
|
||||
], $adminSession['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$updatedUserRow = api_test_runtime()->queryOne(
|
||||
'SELECT `customer_number`, `group_id`, `display_name` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1'
|
||||
);
|
||||
expect((int)($updatedUserRow['customer_number'] ?? 0))->toBe($customerNumber);
|
||||
expect((int)($updatedUserRow['group_id'] ?? 0))->toBe($groupId);
|
||||
expect($updatedUserRow['display_name'] ?? null)->toBe('Edited Limited Listed Employee');
|
||||
});
|
||||
|
||||
it('accepts employees without optional phone details', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee No Phone']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited No Phone',
|
||||
'email' => 'limited-no-phone@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
|
||||
$created
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue();
|
||||
expect(array_key_exists('phone', $created->data()))->toBeTrue();
|
||||
expect($created->data()['phone_country_code'])->toBeNull();
|
||||
expect($created->data()['phone'])->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||
@@ -1106,7 +429,6 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Outside Employee',
|
||||
'email' => 'outside@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$otherDepartment['id']],
|
||||
@@ -1118,7 +440,6 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Raw Employee',
|
||||
'email' => 'raw@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -1131,7 +452,6 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Unknown Role Employee',
|
||||
'email' => 'unknown-role@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'superuser',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -1184,88 +504,3 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Cannot manage shared groups.');
|
||||
});
|
||||
|
||||
it('rejects invalid limited backoffice employee contact details', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Contact Validation']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
$basePayload = [
|
||||
'display_name' => 'Contact Employee',
|
||||
'email' => 'contact@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'viewer',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
];
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', array_diff_key($basePayload, ['email' => true]), $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Email is required.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'email' => 'not-an-email',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Email must be a valid email address.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'phone_country_code' => 45,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone country code and phone number must be provided together.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'phone_country_code' => 1,
|
||||
'phone' => 12345678,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone country code is not supported.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
...$basePayload,
|
||||
'phone_country_code' => 45,
|
||||
'phone' => '12ab',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone values must contain digits only.');
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', $basePayload, $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
|
||||
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'email' => '',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Email is required.');
|
||||
|
||||
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'phone_country_code' => 45,
|
||||
'phone' => '123',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Phone number must be 4-15 digits.');
|
||||
});
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function order_booking_create_payload(array $customer, array $department, array $product, string $reference): array
|
||||
{
|
||||
return [
|
||||
'customer_number' => (int)$customer['customer_number'],
|
||||
'department' => (int)$department['id'],
|
||||
'reg_1' => $reference,
|
||||
'datetime' => '2026-07-07 10:00:00',
|
||||
'note' => '',
|
||||
'reference' => $reference,
|
||||
'po' => '',
|
||||
'pickup' => false,
|
||||
'items' => [
|
||||
[
|
||||
'id' => (int)$product['id'],
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function order_booking_create_department(string $name): array
|
||||
{
|
||||
$branding = api_fixtures()->createBranding([
|
||||
'name' => $name . ' Brand',
|
||||
'address' => 'API Booking Street 1',
|
||||
]);
|
||||
|
||||
return api_fixtures()->createDepartment([
|
||||
'name' => $name,
|
||||
'branding' => (int)$branding['id'],
|
||||
]);
|
||||
}
|
||||
|
||||
it('lets customers create their own order bookings without booking permissions', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'auth');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['user']);
|
||||
$department = order_booking_create_department('Own Booking Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Own Booking Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($session['user'], $department, $product, 'OWNBOOK1'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||
expect($bookingId)->toBeGreaterThan(0);
|
||||
|
||||
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
|
||||
expect($row)->not->toBeNull();
|
||||
expect((int)($row['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']);
|
||||
|
||||
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||
});
|
||||
|
||||
it('blocks subusers creating own customer order bookings without the bookings add node', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'auth');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']);
|
||||
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], []);
|
||||
$department = order_booking_create_department('Subuser Booking Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($customer, $department, $product, 'SUBBOOK1'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['add_own_bookings']);
|
||||
});
|
||||
|
||||
it('lets subusers create own customer order bookings with the bookings add node', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'auth');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer With Add']);
|
||||
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], ['BOOKINGS_ADD']);
|
||||
$department = order_booking_create_department('Subuser Booking Add Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Add Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($customer, $department, $product, 'SUBBOOK2'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||
expect($bookingId)->toBeGreaterThan(0);
|
||||
|
||||
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
|
||||
expect($row)->not->toBeNull();
|
||||
expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']);
|
||||
|
||||
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||
});
|
||||
|
||||
it('still requires elevated access for creating another customer order booking', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'auth');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['user']);
|
||||
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']);
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']);
|
||||
$product = api_fixtures()->createProduct(['name' => 'Other Booking Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['add_bookings']);
|
||||
});
|
||||
|
||||
it('lets department-scoped users create order bookings for another customer', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'happy');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']);
|
||||
$department = order_booking_create_department('Department Scoped Booking Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']);
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'add_bookings',
|
||||
'department_access_' . $department['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||
expect($bookingId)->toBeGreaterThan(0);
|
||||
|
||||
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||
});
|
||||
@@ -36,41 +36,6 @@ function post_order_item(array $order, array $product, array $headers, array $ov
|
||||
], $overrides), $headers);
|
||||
}
|
||||
|
||||
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
|
||||
VALUES (?, 0, ?, ?)'
|
||||
);
|
||||
$productIdText = (string)$productId;
|
||||
$statement->bind_param('isi', $userId, $productIdText, $percentage);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
api_fixtures()->cleanupDeleteWhere('price_overrides', [
|
||||
'user_id' => $userId,
|
||||
'is_category' => 0,
|
||||
'product_or_category_id' => $productIdText,
|
||||
]);
|
||||
}
|
||||
|
||||
function custom_pricing_only_department_price(int $departmentId, int $productId, int $price): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
|
||||
);
|
||||
$statement->bind_param('iii', $departmentId, $productId, $price);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
|
||||
api_test_covers('POST /order/items', 'validation');
|
||||
|
||||
@@ -82,6 +47,7 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
'reference' => 'NOTE-REQUIRED',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902701,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
@@ -115,146 +81,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
||||
});
|
||||
|
||||
it('uses a product fixed price instead of the best discount when adding an order item', function (): void {
|
||||
api_test_covers('POST /order/items', 'pricing');
|
||||
api_test_covers('GET /products', 'pricing');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Fixed Price Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Fixed Price Cashier']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Fixed Price Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Fixed Price Product',
|
||||
'price' => 1000,
|
||||
'category' => $category['id'],
|
||||
'apply_category_discount' => 1,
|
||||
]);
|
||||
|
||||
api_fixtures()->createPriceOverride([
|
||||
'user_id' => $customer['id'],
|
||||
'is_category' => 1,
|
||||
'product_or_category_id' => (string)$category['id'],
|
||||
'percentage' => 80,
|
||||
]);
|
||||
api_fixtures()->createPriceOverride([
|
||||
'user_id' => $customer['id'],
|
||||
'is_category' => 0,
|
||||
'product_or_category_id' => (string)$product['id'],
|
||||
'percentage' => 10,
|
||||
'fixed_price' => 350,
|
||||
]);
|
||||
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'reference' => 'FIXED-PRICE',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products']);
|
||||
|
||||
$productResponse = api_client()->get(
|
||||
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
$productResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($productResponse->data()['price'] ?? null)->toBe(350);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data()['price'] ?? null)->toBe(350);
|
||||
});
|
||||
|
||||
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer_rules');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'ONLY-TANK',
|
||||
]);
|
||||
$washProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Forvogn',
|
||||
'price' => 649,
|
||||
'category' => 4,
|
||||
]);
|
||||
$tankCleaningProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'price' => 299,
|
||||
'category' => 5,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
api_client()
|
||||
->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $washProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $tankCleaningProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
|
||||
});
|
||||
|
||||
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer_rules');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'REGULAR-WASH',
|
||||
]);
|
||||
$washProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Forvogn',
|
||||
'price' => 649,
|
||||
'category' => 4,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $washProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
|
||||
});
|
||||
|
||||
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
||||
api_test_covers('PUT /order/items', 'validation');
|
||||
|
||||
@@ -269,7 +95,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902702,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'name' => 'API Note Required Product',
|
||||
'price' => 199,
|
||||
'requires_note' => 1,
|
||||
]);
|
||||
@@ -281,7 +107,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
||||
'quantity' => 1,
|
||||
'notes' => 'Initial note',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items']);
|
||||
|
||||
api_client()
|
||||
->put('/order/items', [
|
||||
@@ -301,6 +127,7 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
api_test_covers('GET /products', 'happy');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902703,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
@@ -336,9 +163,7 @@ it('blocks addon products added as standalone additional order items for custome
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||
'notes' => 'Addon customer rule check',
|
||||
])
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
@@ -441,77 +266,3 @@ it('only allows tank cleaning products when the customer has the only tank clean
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
|
||||
api_test_covers('GET /products', 'happy');
|
||||
api_test_covers('POST /order/items', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Custom Pricing Products',
|
||||
'custom_pricing_only' => 1,
|
||||
]);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Custom Pricing Missing Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 12345,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
|
||||
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
|
||||
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'list_products',
|
||||
'add_order_items',
|
||||
'department_access_' . (int)$department['id'],
|
||||
]);
|
||||
|
||||
$productResponse = api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id']
|
||||
. '&department_id=' . (int)$department['id']
|
||||
. '&customer_id=' . (int)$customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
$productResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($productResponse->body)->not->toContain('12345');
|
||||
expect($productResponse->body)->not->toContain('3333');
|
||||
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
|
||||
api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id']
|
||||
. '&department_id=' . (int)$otherDepartment['id'],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'CUSTOM-ONLY-ORDER',
|
||||
]);
|
||||
|
||||
$orderItem = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$orderItem
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
});
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('loads a single department overview for superusers without department scoped access', function (): void {
|
||||
api_test_covers('GET /superuser/departments/{id}/overview', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Overview Department ' . uniqid('', false),
|
||||
'description' => 'Department overview fixture',
|
||||
'economic_department_id' => 42,
|
||||
'visible' => 1,
|
||||
]);
|
||||
$departmentRow = api_fixtures()->fetchRowById('departments', (int)$department['id']);
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'superuser_fetch_department',
|
||||
]);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06&date_to=2026-07-06',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $response->data();
|
||||
|
||||
expect($payload)->toBeArray();
|
||||
expect($payload['department'])
|
||||
->toBeArray()
|
||||
->toHaveKey('id', (int)$department['id'])
|
||||
->toHaveKey('name', $departmentRow['name'])
|
||||
->toHaveKey('description', 'Department overview fixture')
|
||||
->toHaveKey('economic_department_id', 42);
|
||||
|
||||
expect($payload['overview'])
|
||||
->toBeArray()
|
||||
->toHaveKey('department_ids', [(int)$department['id']])
|
||||
->toHaveKey('date', '2026-07-06')
|
||||
->toHaveKey('date_to', '2026-07-06');
|
||||
|
||||
expect($payload['overview']['metrics'])
|
||||
->toBeArray()
|
||||
->toHaveKeys([
|
||||
'bookings',
|
||||
'complaints',
|
||||
'night_washes',
|
||||
'revenue',
|
||||
'washes',
|
||||
'products_sold',
|
||||
'transactions',
|
||||
'water_usage',
|
||||
'overtime',
|
||||
]);
|
||||
expect($payload['overview']['metrics']['revenue']['state'])->toBe('ready');
|
||||
expect($payload['overview']['metrics']['revenue']['value'])->toBe(0);
|
||||
expect($payload['overview']['products'])->toBeArray();
|
||||
});
|
||||
|
||||
it('rejects superuser department overview requests without permission or valid input', function (): void {
|
||||
api_test_covers('GET /superuser/departments/{id}/overview', 'auth');
|
||||
api_test_covers('GET /superuser/departments/{id}/overview', 'failure');
|
||||
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$unauthorizedSession = api_fixtures()->createUserSession([]);
|
||||
|
||||
api_client()->get(
|
||||
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06',
|
||||
$unauthorizedSession['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['superuser_fetch_department']);
|
||||
|
||||
$session = api_fixtures()->createUserSession(['superuser_fetch_department']);
|
||||
|
||||
api_client()->get('/superuser/departments/bad/overview?date=2026-07-06', $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Parameter id must be a positive integer');
|
||||
|
||||
api_client()->get('/superuser/departments/' . $department['id'] . '/overview', $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Missing required parameters: date');
|
||||
|
||||
api_client()->get('/superuser/departments/99999999/overview?date=2026-07-06', $session['headers'])
|
||||
->assertStatus(404)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Department not found');
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('sets preserves and clears product fixed prices through the user discounts endpoint', function (): void {
|
||||
api_test_covers('POST /superuser/user/discounts', 'pricing');
|
||||
api_test_covers('GET /superuser/user/discounts', 'pricing');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Endpoint Fixed Price Customer']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Endpoint Fixed Price Product',
|
||||
'price' => 900,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'set_custom_price',
|
||||
'get_custom_prices_other',
|
||||
]);
|
||||
|
||||
$findProductRow = function () use ($customer, $product, $session): array {
|
||||
$response = api_client()->get(
|
||||
'/superuser/user/discounts?user_id=' . $customer['id'],
|
||||
$session['headers']
|
||||
);
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
foreach ($response->data() as $row) {
|
||||
if ((int)($row['product_or_category_id'] ?? 0) === (int)$product['id'] && !($row['is_category'] ?? false)) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Expected product override row was not returned.');
|
||||
};
|
||||
|
||||
api_client()
|
||||
->post('/superuser/user/discounts', [
|
||||
'user_id' => $customer['id'],
|
||||
'object_id' => $product['id'],
|
||||
'is_category' => false,
|
||||
'discount' => 20,
|
||||
'fixed_price' => 350,
|
||||
], $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$row = $findProductRow();
|
||||
expect((int)$row['percentage'])->toBe(20);
|
||||
expect((int)$row['fixed_price'])->toBe(350);
|
||||
|
||||
api_client()
|
||||
->post('/superuser/user/discounts', [
|
||||
'user_id' => $customer['id'],
|
||||
'object_id' => $product['id'],
|
||||
'is_category' => false,
|
||||
'discount' => 10,
|
||||
], $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$row = $findProductRow();
|
||||
expect((int)$row['percentage'])->toBe(10);
|
||||
expect((int)$row['fixed_price'])->toBe(350);
|
||||
|
||||
api_client()
|
||||
->post('/superuser/user/discounts', [
|
||||
'user_id' => $customer['id'],
|
||||
'object_id' => $product['id'],
|
||||
'is_category' => false,
|
||||
'discount' => 10,
|
||||
'fixed_price' => null,
|
||||
], $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$row = $findProductRow();
|
||||
expect((int)$row['percentage'])->toBe(10);
|
||||
expect($row['fixed_price'])->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid fixed price payloads for user discounts', function (): void {
|
||||
api_test_covers('POST /superuser/user/discounts', 'validation');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Invalid Fixed Price Customer']);
|
||||
$product = api_fixtures()->createProduct(['name' => 'Invalid Fixed Price Product']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Invalid Fixed Price Category']);
|
||||
$session = api_fixtures()->createUserSession(['set_custom_price']);
|
||||
|
||||
api_client()
|
||||
->post('/superuser/user/discounts', [
|
||||
'user_id' => $customer['id'],
|
||||
'object_id' => $product['id'],
|
||||
'is_category' => false,
|
||||
'discount' => 10,
|
||||
'fixed_price' => '12.5',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
api_client()
|
||||
->post('/superuser/user/discounts', [
|
||||
'user_id' => $customer['id'],
|
||||
'object_id' => (string)$category['id'],
|
||||
'is_category' => true,
|
||||
'discount' => 10,
|
||||
'fixed_price' => 350,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
});
|
||||
@@ -19,7 +19,6 @@ return [
|
||||
'GET /branding',
|
||||
'POST /branding',
|
||||
'PUT /branding',
|
||||
'GET /superuser/departments/{id}/overview',
|
||||
'PUT /superuser/department/branding',
|
||||
'POST /bird/voice/calls/webhook/inbound',
|
||||
],
|
||||
|
||||
@@ -71,7 +71,6 @@ final class ApiFixtures
|
||||
$this->deleteRedisKey('`users`_' . $customerNumber . '_economic_customer_name');
|
||||
$this->deleteRedisKey('users_' . $userId . '_economic_customer');
|
||||
$this->deleteRedisKey('`users`_' . $userId . '_economic_customer');
|
||||
$this->deleteRedisKey('users_' . $userId . '_economic_customer_discount_percentage');
|
||||
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
|
||||
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
|
||||
});
|
||||
@@ -79,7 +78,6 @@ final class ApiFixtures
|
||||
$economicName = (string)($attributes['economic_customer_name'] ?? $displayName);
|
||||
$this->seedCustomerNameCache($customerNumber, $economicName);
|
||||
$this->seedEconomicCustomerCache($userId, $customerNumber, $economicName, $email);
|
||||
$this->seedEconomicCustomerDiscountCache($userId, (int)($attributes['economic_customer_discount_percentage'] ?? 0));
|
||||
|
||||
return [
|
||||
'id' => $userId,
|
||||
@@ -134,7 +132,6 @@ final class ApiFixtures
|
||||
'branding' => (int)($attributes['branding'] ?? 0),
|
||||
'visible' => (int)($attributes['visible'] ?? 1),
|
||||
'archived' => (int)($attributes['archived'] ?? 0),
|
||||
'custom_pricing_only' => (int)($attributes['custom_pricing_only'] ?? 0),
|
||||
'latitude' => $attributes['latitude'] ?? 0.0,
|
||||
'longitude' => $attributes['longitude'] ?? 0.0,
|
||||
'order_priority' => (int)($attributes['order_priority'] ?? 0),
|
||||
@@ -663,33 +660,6 @@ final class ApiFixtures
|
||||
return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createPriceOverride(array $attributes): array
|
||||
{
|
||||
$userId = (int)($attributes['user_id'] ?? 0);
|
||||
$objectId = (string)($attributes['product_or_category_id'] ?? '');
|
||||
if ($userId <= 0 || $objectId === '') {
|
||||
throw new RuntimeException('Price overrides require user_id and product_or_category_id.');
|
||||
}
|
||||
|
||||
$overrideId = $this->insertRowWithExistingColumns('price_overrides', [
|
||||
'user_id' => $userId,
|
||||
'is_category' => (int)($attributes['is_category'] ?? 0),
|
||||
'product_or_category_id' => $objectId,
|
||||
'percentage' => (int)($attributes['percentage'] ?? 0),
|
||||
'fixed_price' => $attributes['fixed_price'] ?? null,
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('price_overrides', $overrideId));
|
||||
|
||||
return array_merge(['id' => $overrideId], $this->fetchRowById('price_overrides', $overrideId) ?? []);
|
||||
}
|
||||
|
||||
public function linkDepartmentCategory(int $departmentId, int $categoryId): int
|
||||
{
|
||||
$linkId = $this->insertRow('department_categories', [
|
||||
@@ -1739,17 +1709,6 @@ final class ApiFixtures
|
||||
$this->cleanup->add(fn() => $this->deleteWhere($table, $conditions));
|
||||
}
|
||||
|
||||
public function cacheEconomicCustomerDiscountPercentage(int $userId, int $discountPercentage): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.');
|
||||
}
|
||||
|
||||
$key = 'users_' . $userId . '_economic_customer_discount_percentage';
|
||||
$this->redis->set($key, (string)$discountPercentage);
|
||||
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
|
||||
}
|
||||
|
||||
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
|
||||
{
|
||||
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
|
||||
@@ -1834,11 +1793,6 @@ final class ApiFixtures
|
||||
$this->setRedisJson('`users`_' . $userId . '_economic_customer', $payload);
|
||||
}
|
||||
|
||||
private function seedEconomicCustomerDiscountCache(int $userId, int $discountPercentage): void
|
||||
{
|
||||
$this->setRedisValue('users_' . $userId . '_economic_customer_discount_percentage', (string)$discountPercentage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
@@ -2210,16 +2164,6 @@ final class ApiFixtures
|
||||
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
|
||||
}
|
||||
|
||||
private function setRedisValue(string $key, string $value): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.');
|
||||
}
|
||||
|
||||
$this->redis->set($key, $value);
|
||||
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
|
||||
}
|
||||
|
||||
private function deleteRedisKey(string $key): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
|
||||
@@ -21,7 +21,6 @@ final class ApiSchemaBootstrap
|
||||
|
||||
$this->ensureDepartmentArchiveSchema();
|
||||
$this->ensureOrderInvoiceCollectionSchema();
|
||||
$this->ensurePriceOverrideSchema();
|
||||
|
||||
foreach ($this->viewStatements() as $name => $sql) {
|
||||
$this->execute($name, $sql);
|
||||
@@ -90,7 +89,6 @@ CREATE TABLE IF NOT EXISTS `departments` (
|
||||
`branding` INT NULL DEFAULT NULL,
|
||||
`visible` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`archived` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||
`longitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||
`order_priority` INT NOT NULL DEFAULT 0,
|
||||
@@ -113,46 +111,6 @@ CREATE TABLE IF NOT EXISTS `department_variables` (
|
||||
KEY `idx_department_variables_department_id` (`department_id`),
|
||||
KEY `idx_department_variables_variable` (`variable`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_daily_reports' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_daily_reports` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department_id` INT NOT NULL,
|
||||
`water_usage` INT NOT NULL DEFAULT 0,
|
||||
`water_usage_morning` INT NOT NULL DEFAULT 0,
|
||||
`notes` TEXT NULL,
|
||||
`filled_by` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_daily_reports_department_id` (`department_id`),
|
||||
KEY `idx_department_daily_reports_created_at` (`created_at`),
|
||||
KEY `idx_department_daily_reports_department_created_at` (`department_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_time_bookings_opening_hours' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_time_bookings_opening_hours` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department` INT NOT NULL,
|
||||
`monday_start` TIME NULL,
|
||||
`monday_end` TIME NULL,
|
||||
`tuesday_start` TIME NULL,
|
||||
`tuesday_end` TIME NULL,
|
||||
`wednesday_start` TIME NULL,
|
||||
`wednesday_end` TIME NULL,
|
||||
`thursday_start` TIME NULL,
|
||||
`thursday_end` TIME NULL,
|
||||
`friday_start` TIME NULL,
|
||||
`friday_end` TIME NULL,
|
||||
`saturday_start` TIME NULL,
|
||||
`saturday_end` TIME NULL,
|
||||
`sunday_start` TIME NULL,
|
||||
`sunday_end` TIME NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_time_bookings_opening_hours_department` (`department`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_gates' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_gates` (
|
||||
@@ -774,7 +732,6 @@ CREATE TABLE IF NOT EXISTS `price_overrides` (
|
||||
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`product_or_category_id` VARCHAR(191) NOT NULL,
|
||||
`percentage` INT NOT NULL DEFAULT 0,
|
||||
`fixed_price` INT NULL DEFAULT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
@@ -935,13 +892,6 @@ SQL,
|
||||
'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)'
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->columnExists('departments', 'custom_pricing_only')) {
|
||||
$this->execute(
|
||||
'departments.custom_pricing_only',
|
||||
'ALTER TABLE `departments` ADD COLUMN `custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0 AFTER `archived`'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureOrderInvoiceCollectionSchema(): void
|
||||
@@ -982,16 +932,6 @@ SQL,
|
||||
}
|
||||
}
|
||||
|
||||
private function ensurePriceOverrideSchema(): void
|
||||
{
|
||||
if (!$this->columnExists('price_overrides', 'fixed_price')) {
|
||||
$this->execute(
|
||||
'price_overrides.fixed_price',
|
||||
'ALTER TABLE `price_overrides` ADD COLUMN `fixed_price` INT NULL DEFAULT NULL AFTER `percentage`'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
$table = $this->db->real_escape_string($table);
|
||||
|
||||
@@ -62,7 +62,6 @@ it('returns the raw upstream create response and preserves the requested payload
|
||||
expect($probe->inner->lastPayload['phone'])->toBe(42331123);
|
||||
expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123');
|
||||
expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123');
|
||||
expect(array_key_exists('ean', $probe->inner->lastPayload))->toBeFalse();
|
||||
});
|
||||
|
||||
it('adds supported CVR company fields to the e-conomic customer payload', function (): void {
|
||||
@@ -98,46 +97,3 @@ it('adds supported CVR company fields to the e-conomic customer payload', functi
|
||||
expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788');
|
||||
expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse();
|
||||
});
|
||||
|
||||
it('adds a normalized EAN to the e-conomic customer payload when provided', function (): void {
|
||||
$stubResponse = (object)[
|
||||
'customerNumber' => 42331123,
|
||||
'name' => 'Truckwash ApS',
|
||||
];
|
||||
|
||||
$probe = new EconomicCreateCustomerProbe($stubResponse);
|
||||
$probe->createCustomer(
|
||||
42331123,
|
||||
'Truckwash ApS',
|
||||
37781258,
|
||||
'invoice@truckwash.test',
|
||||
42331123,
|
||||
null,
|
||||
null,
|
||||
'57 90-001234567',
|
||||
);
|
||||
|
||||
expect($probe->inner->lastPayload['ean'])->toBe('5790001234567');
|
||||
});
|
||||
|
||||
it('rejects EAN values longer than e-conomic accepts', function (): void {
|
||||
$stubResponse = (object)[
|
||||
'customerNumber' => 42331123,
|
||||
'name' => 'Truckwash ApS',
|
||||
];
|
||||
|
||||
$probe = new EconomicCreateCustomerProbe($stubResponse);
|
||||
$call = static fn() => $probe->createCustomer(
|
||||
42331123,
|
||||
'Truckwash ApS',
|
||||
37781258,
|
||||
'invoice@truckwash.test',
|
||||
42331123,
|
||||
null,
|
||||
null,
|
||||
'57900012345678',
|
||||
);
|
||||
|
||||
expect($call)->toThrow(InvalidArgumentException::class, 'EAN must be at most 13 digits.');
|
||||
expect($probe->inner->lastPayload)->toBe([]);
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?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,22 +176,6 @@ it('creates a new e-conomic customer and returns a created result for new rows',
|
||||
expect($result['has_account'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects EAN values longer than e-conomic accepts before creating customers', function (): void {
|
||||
$service = new CustomerMassImportServiceProbe();
|
||||
|
||||
$call = static fn() => $service->import([
|
||||
'cvr' => '29424764',
|
||||
'name' => 'TGP TRANSPORT APS',
|
||||
'email' => 'tgp@example.com',
|
||||
'ean' => '57900012345678',
|
||||
'phone' => '22725567',
|
||||
]);
|
||||
|
||||
expect($call)->toThrow(RuntimeException::class, 'EAN must be at most 13 digits.');
|
||||
expect($service->createCalls)->toBe([]);
|
||||
expect($service->bootstrapCalls)->toBe([]);
|
||||
});
|
||||
|
||||
it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void {
|
||||
$service = new CustomerMassImportServiceProbe();
|
||||
$service->localExists = true;
|
||||
|
||||
-3
@@ -31,11 +31,8 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
|
||||
$content = department_daily_reports_openapi_content_or_skip();
|
||||
|
||||
expect($content)->toContain('/departments/daily-reports/overview:');
|
||||
expect($content)->toContain('/superuser/departments/{id}/overview:');
|
||||
expect($content)->toContain('operationId: getDailyReportOverview');
|
||||
expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
|
||||
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
|
||||
expect($content)->toContain('SuperuserDepartmentOverviewResponse:');
|
||||
expect($content)->toContain('DepartmentDailyReportMetric:');
|
||||
expect($content)->toContain('DepartmentDailyReportProductTile:');
|
||||
expect($content)->toContain('- name: department_ids');
|
||||
|
||||
@@ -342,8 +342,6 @@ it('wires the overview route to batched repository methods and overview path', f
|
||||
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
|
||||
|
||||
expect($routeContent)->toContain('/departments/daily-reports/overview');
|
||||
expect($routeContent)->toContain('/superuser/departments/{id}/overview');
|
||||
expect($routeContent)->toContain('superuser_fetch_department');
|
||||
expect($routeContent)->toContain('/departments/daily-reports/complaints');
|
||||
expect($routeContent)->toContain('outsideHoursStatisticsService');
|
||||
expect($routeContent)->toContain('dailyReportComplaintsRepository');
|
||||
|
||||
@@ -76,6 +76,4 @@ it('defines error report schema, routes, permissions, storage, and OpenAPI docs'
|
||||
expect($openapi)->toContain('/error-reports:');
|
||||
expect($openapi)->toContain('ErrorReportSubmissionRequest');
|
||||
expect($openapi)->toContain('ErrorReportStatusUpdateRequest');
|
||||
expect($openapi)->not->toContain(" - screenshot\n");
|
||||
expect($openapi)->toContain('Reports are accepted without an attachment when capture or upload fails.');
|
||||
});
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('routes collected invoice draft line uploads through the multi-order batch endpoint', function (): void {
|
||||
$content = file_get_contents(dirname(__DIR__, 3) . '/objects/collected_order_invoices_o.php');
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
$start = strpos($content, 'public function addInvoicesToDraft');
|
||||
$end = strpos($content, 'public function getLastEconomicTransferMetrics');
|
||||
expect($start)->not->toBeFalse();
|
||||
expect($end)->not->toBeFalse();
|
||||
expect($end)->toBeGreaterThan($start);
|
||||
|
||||
$methodBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
||||
expect($methodBlock)->toContain('$order_objects = [];')
|
||||
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);')
|
||||
->and($methodBlock)->toContain('...$metrics')
|
||||
->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);');
|
||||
});
|
||||
|
||||
it('keeps single-order draft uploads as a wrapper around the batch endpoint', function (): void {
|
||||
$content = file_get_contents(dirname(__DIR__, 3) . '/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php');
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
$singleStart = strpos($content, 'public function add_order');
|
||||
$singleEnd = strpos($content, 'public function add_orders');
|
||||
expect($singleStart)->not->toBeFalse();
|
||||
expect($singleEnd)->not->toBeFalse();
|
||||
expect($singleEnd)->toBeGreaterThan($singleStart);
|
||||
|
||||
$singleBlock = substr($content, (int)$singleStart, (int)$singleEnd - (int)$singleStart);
|
||||
expect($singleBlock)->toContain('$this->add_orders($invoiceDraftId, [$order], $currency);');
|
||||
|
||||
$batchBlock = substr($content, (int)$singleEnd);
|
||||
expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);')
|
||||
->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines");
|
||||
});
|
||||
|
||||
it('includes collected invoice batch transfer metrics in queue results when available', function (): void {
|
||||
$content = file_get_contents(dirname(__DIR__, 3) . '/classes/economic_transfer_executor.php');
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain('$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();')
|
||||
->and($content)->toContain("\$result['economic_transfer_metrics'] = \$transfer_metrics;");
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
<?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();
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
<?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}$'");
|
||||
}
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
use helpers\economic_invoice_draft;
|
||||
|
||||
if (!class_exists('EconomicInvoiceDraftBatchingProbe')) {
|
||||
class EconomicInvoiceDraftBatchingProbe extends economic_invoice_draft
|
||||
{
|
||||
public array $sentBatches = [];
|
||||
|
||||
protected function sendDraftLines(array $draft_lines): object
|
||||
{
|
||||
$this->sentBatches[] = $draft_lines;
|
||||
return (object)['lines' => $draft_lines];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('EconomicInvoiceDraftFailingBatchingProbe')) {
|
||||
class EconomicInvoiceDraftFailingBatchingProbe extends EconomicInvoiceDraftBatchingProbe
|
||||
{
|
||||
public int $failOnBatch = 1;
|
||||
|
||||
protected function sendDraftLines(array $draft_lines): object
|
||||
{
|
||||
if (count($this->sentBatches) + 1 === $this->failOnBatch) {
|
||||
throw new RuntimeException('Simulated e-conomic line batch failure');
|
||||
}
|
||||
|
||||
return parent::sendDraftLines($draft_lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('does not call e-conomic when flushing an empty draft line buffer', function (): void {
|
||||
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||
|
||||
$metrics = $draft->flushLinesInBatches();
|
||||
|
||||
expect($metrics)->toBe([
|
||||
'line_count' => 0,
|
||||
'batch_count' => 0,
|
||||
'batch_sizes' => [],
|
||||
])->and($draft->sentBatches)->toBe([]);
|
||||
});
|
||||
|
||||
it('flushes a small draft line buffer in one request and clears pending lines', function (): void {
|
||||
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||
$draft->addTextLine('line-0');
|
||||
$draft->addTextLine('line-1');
|
||||
$draft->addTextLine('line-2');
|
||||
|
||||
$metrics = $draft->flushLinesInBatches(500);
|
||||
|
||||
expect($metrics)->toBe([
|
||||
'line_count' => 3,
|
||||
'batch_count' => 1,
|
||||
'batch_sizes' => [3],
|
||||
])->and($draft->sentBatches)->toHaveCount(1)
|
||||
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
|
||||
->and($draft->sentBatches[0][2]['description'])->toBe('line-2')
|
||||
->and($draft->pendingLineCount())->toBe(0);
|
||||
});
|
||||
|
||||
it('chunks large draft line buffers while preserving line order', function (): void {
|
||||
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||
for ($i = 0; $i < 1201; $i++) {
|
||||
$draft->addTextLine('line-' . $i);
|
||||
}
|
||||
|
||||
$metrics = $draft->flushLinesInBatches(500);
|
||||
|
||||
expect($metrics)->toBe([
|
||||
'line_count' => 1201,
|
||||
'batch_count' => 3,
|
||||
'batch_sizes' => [500, 500, 201],
|
||||
])->and($draft->sentBatches)->toHaveCount(3)
|
||||
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
|
||||
->and($draft->sentBatches[1][0]['description'])->toBe('line-500')
|
||||
->and($draft->sentBatches[2][200]['description'])->toBe('line-1200')
|
||||
->and($draft->pendingLineCount())->toBe(0);
|
||||
});
|
||||
|
||||
it('bubbles line batch failures and keeps pending lines available', function (): void {
|
||||
$draft = new EconomicInvoiceDraftFailingBatchingProbe(123, 'DKK', true);
|
||||
$draft->addTextLine('line-0');
|
||||
|
||||
expect(fn () => $draft->flushLinesInBatches(500))
|
||||
->toThrow(RuntimeException::class, 'Simulated e-conomic line batch failure');
|
||||
|
||||
expect($draft->sentBatches)->toBe([])
|
||||
->and($draft->pendingLineCount())->toBe(1);
|
||||
});
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
<?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
@@ -1,167 +0,0 @@
|
||||
<?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,33 +579,6 @@ it('uses the highest customer-specific discount in expected price breakdowns', f
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses a product fixed price before customer discounts in expected price breakdowns', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 0,
|
||||
'product_base_price' => 1000,
|
||||
'department_price' => null,
|
||||
'product_fixed_price' => 350,
|
||||
'product_discount_percentage' => 10,
|
||||
'category_discount_percentage' => 80,
|
||||
'apply_category_discount' => 1,
|
||||
];
|
||||
|
||||
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
|
||||
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
|
||||
|
||||
expect($expected)->toBe(350);
|
||||
expect($breakdown)->toMatchArray([
|
||||
'product_price' => 1000,
|
||||
'effective_base_price' => 1000,
|
||||
'product_fixed_price' => 350,
|
||||
'product_discount_percentage' => 10,
|
||||
'category_discount_percentage' => 80,
|
||||
'economic_customer_discount_percentage' => 0,
|
||||
'applied_discount_percentage' => 0,
|
||||
'expected_price' => 350,
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void {
|
||||
$service = invoice_period_flag_service_instance();
|
||||
$reflection = new ReflectionClass(invoice_period_flag_service::class);
|
||||
@@ -645,33 +618,6 @@ it('uses a preloaded e-conomic global discount in expected price breakdowns', fu
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the custom-only sentinel without discounts when department price is missing', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 35131752,
|
||||
'user_id' => 411,
|
||||
'product_base_price' => 100,
|
||||
'department_price' => null,
|
||||
'department_custom_pricing_only' => 1,
|
||||
'product_discount_percentage' => 50,
|
||||
'category_discount_percentage' => 25,
|
||||
'apply_category_discount' => 0,
|
||||
];
|
||||
|
||||
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
|
||||
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
|
||||
|
||||
expect($expected)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
expect($breakdown)->toMatchArray([
|
||||
'product_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||
'department_price' => null,
|
||||
'effective_base_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||
'product_discount_percentage' => 50,
|
||||
'category_discount_percentage' => 0,
|
||||
'applied_discount_percentage' => 0,
|
||||
'expected_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 35131752,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\customer_order_product_policy;
|
||||
|
||||
it('recognizes tankcleaning products by category and legacy names', function (): void {
|
||||
expect(customer_order_product_policy::isTankCleaningProductRow([
|
||||
'product_category' => 5,
|
||||
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'category_name' => 'Other',
|
||||
]))->toBeTrue()
|
||||
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||
'product_category' => 3,
|
||||
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||
'category_name' => 'Other',
|
||||
]))->toBeTrue()
|
||||
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||
'product_category' => 3,
|
||||
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'category_name' => 'Tankrens',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects only tankcleaning violations only for attributed customers and non-tank products', function (): void {
|
||||
$washProduct = [
|
||||
'product_category' => 4,
|
||||
'product_name' => 'Forvogn',
|
||||
'category_name' => 'Udvendig',
|
||||
];
|
||||
$tankCleaningProduct = [
|
||||
'product_category' => 5,
|
||||
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||
'category_name' => 'Tank cleaning',
|
||||
];
|
||||
|
||||
expect(customer_order_product_policy::onlyTankCleaningViolation(true, $washProduct))->toBeTrue()
|
||||
->and(customer_order_product_policy::onlyTankCleaningViolation(true, $tankCleaningProduct))->toBeFalse()
|
||||
->and(customer_order_product_policy::onlyTankCleaningViolation(false, $washProduct))->toBeFalse();
|
||||
});
|
||||
@@ -18,8 +18,6 @@ it('parses cached economic customer payloads that use snake_case customer_number
|
||||
'customer_number' => '42331123',
|
||||
'name' => 'Truckwash ApS',
|
||||
'email' => 'jb@truckwash.dk',
|
||||
'ean' => '5790001234567',
|
||||
'public_entry_number' => 'DK123456789',
|
||||
'currency' => 'DKK',
|
||||
'country' => 'DK',
|
||||
'barred' => true,
|
||||
@@ -33,8 +31,6 @@ it('parses cached economic customer payloads that use snake_case customer_number
|
||||
'zip' => null,
|
||||
'corporateIdentificationNumber' => null,
|
||||
'email' => 'jb@truckwash.dk',
|
||||
'ean' => '5790001234567',
|
||||
'publicEntryNumber' => 'DK123456789',
|
||||
'mobilePhone' => null,
|
||||
'currency' => 'DKK',
|
||||
'country' => 'DK',
|
||||
|
||||
@@ -105,30 +105,7 @@ namespace classes {
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null): object
|
||||
{
|
||||
self::$create_calls[] = [
|
||||
'number' => (int)$number,
|
||||
@@ -138,7 +115,6 @@ namespace classes {
|
||||
'phone' => (int)$phone,
|
||||
'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone,
|
||||
'company_information' => $companyInformation,
|
||||
'ean' => $ean === null ? null : (string)$ean,
|
||||
];
|
||||
|
||||
if (self::$mock_create_exception !== null) {
|
||||
@@ -448,16 +424,6 @@ namespace {
|
||||
'expected_error' => 'Parameter cvr must be at least 8 characters long',
|
||||
'expected_status' => 400,
|
||||
],
|
||||
[
|
||||
'name' => 'Invalid EAN length (too long)',
|
||||
'params' => array_merge($baseParams, ['ean' => '57900012345678']),
|
||||
'expected_error' => 'EAN must be at most 13 digits.',
|
||||
'expected_status' => 400,
|
||||
'assert' => static function (): void {
|
||||
assert_true(count(\classes\economic::$create_calls) === 0, 'Invalid EAN must not create e-conomic customers.');
|
||||
assert_true(count(\classes\email::$sent) === 0, 'Invalid EAN must not send welcome emails.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'CVR lookup failure returns validation error without creating customer',
|
||||
'params' => array_merge($baseParams, ['cvr' => '11111112']),
|
||||
@@ -597,7 +563,7 @@ namespace {
|
||||
],
|
||||
[
|
||||
'name' => 'Successful registration bootstraps local user before welcome emails',
|
||||
'params' => array_merge($baseParams, ['contactPhone' => 87654320, 'ean' => '57 90-001234567']),
|
||||
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
|
||||
'setup' => static function (): void {
|
||||
\classes\economic::$mock_create_response = (object)[
|
||||
'customerNumber' => 12345678,
|
||||
@@ -613,7 +579,6 @@ namespace {
|
||||
assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.');
|
||||
assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.');
|
||||
assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.');
|
||||
assert_true(\classes\economic::$create_calls[0]['ean'] === '5790001234567', 'Fresh registration must pass normalized EAN to e-conomic.');
|
||||
$companyInformation = \classes\economic::$create_calls[0]['company_information'];
|
||||
assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.');
|
||||
assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.');
|
||||
|
||||
Reference in New Issue
Block a user