Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae1fc3fcf | ||
|
|
f262047476 | ||
|
|
b8390ac0d3 | ||
|
|
0d4a5470e5 | ||
|
|
845ca6e48e | ||
|
|
1cda2a81aa | ||
|
|
8e46ce1b04 | ||
|
|
11c2a1b72e | ||
|
|
62f2c80dda | ||
|
|
430c90cbca | ||
|
|
84dec4c0a2 | ||
|
|
d47ea1d659 |
+4
-3
@@ -13388,7 +13388,6 @@ components:
|
|||||||
- expected
|
- expected
|
||||||
- actual
|
- actual
|
||||||
- data_collection_accepted
|
- data_collection_accepted
|
||||||
- screenshot
|
|
||||||
properties:
|
properties:
|
||||||
before_error:
|
before_error:
|
||||||
type: string
|
type: string
|
||||||
@@ -13404,10 +13403,11 @@ components:
|
|||||||
description: What actually happened
|
description: What actually happened
|
||||||
data_collection_accepted:
|
data_collection_accepted:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Required acceptance of collecting screenshot and diagnostic error data
|
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
|
||||||
screenshot:
|
screenshot:
|
||||||
type: string
|
type: string
|
||||||
description: PNG, JPEG, or WebP data URI of the current app viewport
|
nullable: true
|
||||||
|
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
|
||||||
route_path:
|
route_path:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -13518,6 +13518,7 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
screenshot:
|
screenshot:
|
||||||
type: object
|
type: object
|
||||||
|
nullable: true
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
answers:
|
answers:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -928,22 +928,20 @@ async function main() {
|
|||||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||||
);
|
);
|
||||||
|
|
||||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
await waitForCondition(
|
||||||
token: authToken,
|
async () => {
|
||||||
});
|
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
token: authToken,
|
||||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
});
|
||||||
: [];
|
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||||
|
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||||
|
: [];
|
||||||
|
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||||
|
|
||||||
assert.ok(
|
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
|
||||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
|
||||||
"Gateway logs page did not persist the shell transcript."
|
},
|
||||||
);
|
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
|
||||||
|
|
||||||
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");
|
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class customer_order_product_policy
|
||||||
|
{
|
||||||
|
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||||
|
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||||
|
|
||||||
|
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||||
|
{
|
||||||
|
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||||
|
if ($message !== null) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||||
|
{
|
||||||
|
$context = self::loadOrderProductContext($orderId, $productId);
|
||||||
|
if ($context === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||||
|
? self::ONLY_TANKCLEANING_MESSAGE
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||||
|
{
|
||||||
|
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isTankCleaningProductRow(array $row): bool
|
||||||
|
{
|
||||||
|
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||||
|
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if ($orderId < 1 || $productId < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
o.id AS order_id,
|
||||||
|
o.customer_id AS customer_number,
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
p.category AS product_category,
|
||||||
|
c.name AS category_name,
|
||||||
|
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN products p ON p.id = {$productId}
|
||||||
|
LEFT JOIN categories c ON c.id = p.category
|
||||||
|
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||||
|
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||||
|
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||||
|
WHERE o.id = {$orderId}
|
||||||
|
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||||
|
LIMIT 1
|
||||||
|
";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result || $result->num_rows < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||||
|
{
|
||||||
|
$haystack = strtolower(trim(
|
||||||
|
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||||
|
(string)($row['category_name'] ?? '')
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use objects\orders_o;
|
||||||
|
use objects\products_o;
|
||||||
|
use objects\users_o;
|
||||||
|
|
||||||
|
class customer_product_rule_service
|
||||||
|
{
|
||||||
|
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||||
|
|
||||||
|
private const ADDON_CATEGORY_ID = 4;
|
||||||
|
private const TANK_CLEANING_CATEGORY_ID = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{rule:string,message:string}|null
|
||||||
|
*/
|
||||||
|
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||||
|
{
|
||||||
|
$order = (new orders_o())->getOrderById($orderId);
|
||||||
|
if (!$order->exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = (new products_o())->getProductById($productId);
|
||||||
|
if (!$product->exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
|
||||||
|
if (!$customer->exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryId = (int)$product->category->value();
|
||||||
|
$categoryName = $this->categoryName($categoryId);
|
||||||
|
$searchableProduct = $this->searchableProductText($product, $categoryName);
|
||||||
|
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
||||||
|
|
||||||
|
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
||||||
|
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
||||||
|
return $this->violation('restrictAdditionalServices');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
|
||||||
|
return $this->violation('restrictTankCleaning');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||||
|
return $this->violation('onlyTankCleaning');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer->doesUserHaveAttribute('restrictSpotFree')
|
||||||
|
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
|
||||||
|
return $this->violation('restrictSpotFree');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
|
||||||
|
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
|
||||||
|
return $this->violation('restrictInteriorCleaning');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{rule:string,message:string}
|
||||||
|
*/
|
||||||
|
private function violation(string $rule): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'rule' => $rule,
|
||||||
|
'message' => self::BLOCK_MESSAGE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
||||||
|
{
|
||||||
|
if ($relatedItemId !== null && $relatedItemId > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->countStandaloneOrderItems($orderId) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
||||||
|
{
|
||||||
|
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function searchableProductText(products_o $product, string $categoryName): string
|
||||||
|
{
|
||||||
|
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $terms
|
||||||
|
*/
|
||||||
|
private function containsAny(string $value, array $terms): bool
|
||||||
|
{
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
if ($term !== '' && str_contains($value, $term)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function categoryName(int $categoryId): string
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if ($categoryId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
|
||||||
|
if (!$result || $result->num_rows === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
return strtolower((string)($row['name'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countStandaloneOrderItems(int $orderId): int
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$result = $db->query(
|
||||||
|
'SELECT COUNT(*) AS item_count
|
||||||
|
FROM order_items
|
||||||
|
WHERE order_id = ' . $orderId . '
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (related_item_id IS NULL OR related_item_id = 0)'
|
||||||
|
);
|
||||||
|
if (!$result) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
return (int)($row['item_count'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
||||||
|
$db->query(
|
||||||
|
"ALTER TABLE departments
|
||||||
|
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
AFTER archived"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||||
$db->query(
|
$db->query(
|
||||||
"ALTER TABLE departments
|
"ALTER TABLE departments
|
||||||
|
|||||||
@@ -92,12 +92,17 @@ class error_report_service
|
|||||||
throw new RuntimeException('Data collection acceptance is required.');
|
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'] : [];
|
$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);
|
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
$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(
|
$this->execute(
|
||||||
"INSERT INTO error_reports (
|
"INSERT INTO error_reports (
|
||||||
@@ -295,6 +300,67 @@ class error_report_service
|
|||||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
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
|
private function runtimeContext(array $payload, array $context): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -432,6 +498,10 @@ class error_report_service
|
|||||||
|
|
||||||
private function publicReport(array $row, bool $includeDetail): array
|
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 = [
|
$report = [
|
||||||
'id' => (int)$row['id'],
|
'id' => (int)$row['id'],
|
||||||
'status' => (string)$row['status'],
|
'status' => (string)$row['status'],
|
||||||
@@ -449,10 +519,10 @@ class error_report_service
|
|||||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||||
'frontend_version' => $row['frontend_version'] ?? null,
|
'frontend_version' => $row['frontend_version'] ?? null,
|
||||||
'api_version' => $row['api_version'] ?? null,
|
'api_version' => $row['api_version'] ?? null,
|
||||||
'screenshot' => [
|
'screenshot' => $hasScreenshot ? [
|
||||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
'mime_type' => $screenshotMimeType,
|
||||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
'size_bytes' => $screenshotSizeBytes,
|
||||||
],
|
] : null,
|
||||||
'answers' => [
|
'answers' => [
|
||||||
'before_error' => $row['before_error'] ?? '',
|
'before_error' => $row['before_error'] ?? '',
|
||||||
'expected' => $row['expected'] ?? '',
|
'expected' => $row['expected'] ?? '',
|
||||||
@@ -467,8 +537,11 @@ class error_report_service
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($includeDetail) {
|
if ($includeDetail) {
|
||||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
if ($hasScreenshot) {
|
||||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
||||||
|
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
||||||
|
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
||||||
|
}
|
||||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ class invoice_period_flag_service
|
|||||||
o.po AS order_po,
|
o.po AS order_po,
|
||||||
o.notes AS order_notes,
|
o.notes AS order_notes,
|
||||||
o.department_id,
|
o.department_id,
|
||||||
|
d.custom_pricing_only AS department_custom_pricing_only,
|
||||||
o.reg_1,
|
o.reg_1,
|
||||||
o.invoice_collection_id,
|
o.invoice_collection_id,
|
||||||
o.wash_id,
|
o.wash_id,
|
||||||
@@ -720,6 +721,7 @@ class invoice_period_flag_service
|
|||||||
GROUP BY customer_number
|
GROUP BY customer_number
|
||||||
) u ON u.customer_number = o.customer_id
|
) 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 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 products p ON p.id = oi.product_id
|
||||||
LEFT JOIN categories c ON c.id = p.category
|
LEFT JOIN categories c ON c.id = p.category
|
||||||
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
||||||
@@ -1921,19 +1923,26 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
private function calculateExpectedPrice(array $row): int
|
private function calculateExpectedPrice(array $row): int
|
||||||
{
|
{
|
||||||
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
$base = $row['department_price'] !== null
|
||||||
|
? (int)$row['department_price']
|
||||||
|
: ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||||
|
$discount = $customMissingPrice ? 0 : $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||||
return (int)round($base * (1 - ($discount / 100)));
|
return (int)round($base * (1 - ($discount / 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function priceBreakdown(array $row, int $expected): array
|
private function priceBreakdown(array $row, int $expected): array
|
||||||
{
|
{
|
||||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||||
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||||
|
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||||
$discount = $this->discountBreakdown($row);
|
$discount = $this->discountBreakdown($row);
|
||||||
|
if ($customMissingPrice) {
|
||||||
|
$discount['applied_discount_percentage'] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'product_price' => (int)($row['product_base_price'] ?? 0),
|
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
||||||
'department_price' => $departmentPrice,
|
'department_price' => $departmentPrice,
|
||||||
'effective_base_price' => $base,
|
'effective_base_price' => $base,
|
||||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||||
@@ -1944,6 +1953,11 @@ 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
|
private function discountBreakdown(array $row): array
|
||||||
{
|
{
|
||||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||||
@@ -2036,8 +2050,7 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
private function rowIsTankCleaningProduct(array $row): bool
|
private function rowIsTankCleaningProduct(array $row): bool
|
||||||
{
|
{
|
||||||
return (int)($row['product_category'] ?? 0) === 5
|
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isIncludedOrderItem(array $row): bool
|
private function isIncludedOrderItem(array $row): bool
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace classes;
|
namespace classes;
|
||||||
|
|
||||||
use mysqli;
|
use mysqli;
|
||||||
|
use objects\products_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
|
|
||||||
class limited_backoffice_service
|
class limited_backoffice_service
|
||||||
@@ -247,6 +248,7 @@ class limited_backoffice_service
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
departments_schema_bootstrap::ensureTables();
|
||||||
limited_backoffice_schema_bootstrap::ensureTables();
|
limited_backoffice_schema_bootstrap::ensureTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +358,7 @@ class limited_backoffice_service
|
|||||||
|
|
||||||
$in = implode(',', array_map('intval', $departmentIds));
|
$in = implode(',', array_map('intval', $departmentIds));
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT `id`, `name`, `description`, `visible`, `archived`
|
SELECT `id`, `name`, `description`, `visible`, `archived`, `custom_pricing_only`
|
||||||
FROM `departments`
|
FROM `departments`
|
||||||
WHERE `id` IN ($in)
|
WHERE `id` IN ($in)
|
||||||
ORDER BY `order_priority` ASC, `name` ASC, `id` ASC
|
ORDER BY `order_priority` ASC, `name` ASC, `id` ASC
|
||||||
@@ -371,6 +373,7 @@ class limited_backoffice_service
|
|||||||
'description' => (string)($row['description'] ?? ''),
|
'description' => (string)($row['description'] ?? ''),
|
||||||
'visible' => (bool)($row['visible'] ?? false),
|
'visible' => (bool)($row['visible'] ?? false),
|
||||||
'archived' => (bool)($row['archived'] ?? false),
|
'archived' => (bool)($row['archived'] ?? false),
|
||||||
|
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
|
||||||
], $rows);
|
], $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,8 +389,9 @@ class limited_backoffice_service
|
|||||||
throw new limited_backoffice_exception('Department not found', 404);
|
throw new limited_backoffice_exception('Department not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$catalog = $this->departmentProductCatalog($departmentId);
|
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
|
||||||
if ($catalog['missing_products'] !== []) {
|
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
|
||||||
|
if (!$customPricingOnly && $catalog['missing_products'] !== []) {
|
||||||
throw new limited_backoffice_exception('Department price setup is incomplete.', 409, [
|
throw new limited_backoffice_exception('Department price setup is incomplete.', 409, [
|
||||||
'message' => 'Department price setup is incomplete.',
|
'message' => 'Department price setup is incomplete.',
|
||||||
'code' => 'department_price_setup_required',
|
'code' => 'department_price_setup_required',
|
||||||
@@ -419,7 +423,8 @@ class limited_backoffice_service
|
|||||||
throw new limited_backoffice_exception('Department not found', 404);
|
throw new limited_backoffice_exception('Department not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$catalog = $this->departmentProductCatalog($departmentId);
|
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
|
||||||
|
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
|
||||||
if ($catalog['required_product_ids'] === []) {
|
if ($catalog['required_product_ids'] === []) {
|
||||||
throw new limited_backoffice_exception('Department has no products configured.', 409);
|
throw new limited_backoffice_exception('Department has no products configured.', 409);
|
||||||
}
|
}
|
||||||
@@ -436,7 +441,7 @@ class limited_backoffice_service
|
|||||||
sort($providedProductIds);
|
sort($providedProductIds);
|
||||||
$missingProductIds = array_values(array_diff($requiredProductIds, $providedProductIds));
|
$missingProductIds = array_values(array_diff($requiredProductIds, $providedProductIds));
|
||||||
|
|
||||||
if ($missingProductIds !== []) {
|
if (!$customPricingOnly && $missingProductIds !== []) {
|
||||||
throw new limited_backoffice_exception('Price is required for every department product.', 400, [
|
throw new limited_backoffice_exception('Price is required for every department product.', 400, [
|
||||||
'message' => 'Price is required for every department product.',
|
'message' => 'Price is required for every department product.',
|
||||||
'missing_product_ids' => $missingProductIds,
|
'missing_product_ids' => $missingProductIds,
|
||||||
@@ -789,13 +794,13 @@ class limited_backoffice_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{id:int,name:string,description:string}
|
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
|
||||||
*/
|
*/
|
||||||
private function fetchDepartment(int $departmentId): ?array
|
private function fetchDepartment(int $departmentId): ?array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$statement = $this->mysqli()->prepare(
|
$statement = $this->mysqli()->prepare(
|
||||||
'SELECT `id`, `name`, `description` FROM `departments` WHERE `id` = ? LIMIT 1'
|
'SELECT `id`, `name`, `description`, `custom_pricing_only` FROM `departments` WHERE `id` = ? LIMIT 1'
|
||||||
);
|
);
|
||||||
if ($statement === false) {
|
if ($statement === false) {
|
||||||
throw new limited_backoffice_exception('Unable to load department.', 500);
|
throw new limited_backoffice_exception('Unable to load department.', 500);
|
||||||
@@ -814,13 +819,14 @@ class limited_backoffice_service
|
|||||||
'id' => (int)$row['id'],
|
'id' => (int)$row['id'],
|
||||||
'name' => (string)$row['name'],
|
'name' => (string)$row['name'],
|
||||||
'description' => (string)($row['description'] ?? ''),
|
'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>}
|
* @return array{categories:array<int,array<string,mixed>>,missing_products:array<int,array<string,mixed>>,required_product_ids:array<int,int>}
|
||||||
*/
|
*/
|
||||||
private function departmentProductCatalog(int $departmentId): array
|
private function departmentProductCatalog(int $departmentId, bool $customPricingOnly = false): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
@@ -881,10 +887,12 @@ class limited_backoffice_service
|
|||||||
'id' => $productId,
|
'id' => $productId,
|
||||||
'name' => (string)$row['product_name'],
|
'name' => (string)$row['product_name'],
|
||||||
'description' => (string)($row['product_description'] ?? ''),
|
'description' => (string)($row['product_description'] ?? ''),
|
||||||
'price' => $row['department_price'] === null ? null : (int)$row['department_price'],
|
'price' => $row['department_price'] === null
|
||||||
|
? ($customPricingOnly ? products_o::CUSTOM_PRICING_MISSING_PRICE : null)
|
||||||
|
: (int)$row['department_price'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($row['department_price_id'] === null) {
|
if ($row['department_price_id'] === null && !$customPricingOnly) {
|
||||||
$missing[] = [
|
$missing[] = [
|
||||||
'id' => $productId,
|
'id' => $productId,
|
||||||
'name' => (string)$row['product_name'],
|
'name' => (string)$row['product_name'],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class departments_o extends db
|
|||||||
public object_property $dimension; // The dimension of the department
|
public object_property $dimension; // The dimension of the department
|
||||||
public object_property $visible; // The visibility of the department
|
public object_property $visible; // The visibility of the department
|
||||||
public object_property $archived; // Whether the department is archived
|
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 $branding; // The branding of the department
|
||||||
public object_property $longitude; // The longitude of the department (Can be null)
|
public object_property $longitude; // The longitude of the department (Can be null)
|
||||||
public object_property $latitude; // The latitude of the department (Can be null)
|
public object_property $latitude; // The latitude of the department (Can be null)
|
||||||
@@ -107,6 +108,7 @@ class departments_o extends db
|
|||||||
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
||||||
$this->visible = new object_property($this->table, $this->id, 'visible', '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->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->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
|
||||||
$this->latitude = new object_property($this->table, $this->id, 'latitude', '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);
|
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||||
@@ -185,6 +187,12 @@ class departments_o extends db
|
|||||||
return $department;
|
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
|
* Get the price of a product in a department
|
||||||
* @param int $department_id
|
* @param int $department_id
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\customer_order_product_policy;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
@@ -93,6 +94,7 @@ class order_items_o extends db
|
|||||||
{
|
{
|
||||||
global $db, $response;
|
global $db, $response;
|
||||||
try {
|
try {
|
||||||
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Avoid SQL injection
|
// Avoid SQL injection
|
||||||
$reference = $db->escape_string($reference);
|
$reference = $db->escape_string($reference);
|
||||||
$notes = $db->escape_string($notes);
|
$notes = $db->escape_string($notes);
|
||||||
@@ -167,13 +169,16 @@ class order_items_o extends db
|
|||||||
try {
|
try {
|
||||||
// Get the order
|
// Get the order
|
||||||
$order = (new orders_o())->getOrderById($order_id);
|
$order = (new orders_o())->getOrderById($order_id);
|
||||||
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Get the product price
|
// Get the product price
|
||||||
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
$product = (new products_o())->getProductById($product_id);
|
||||||
|
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
||||||
|
$price = $priceResolution['price'];
|
||||||
|
|
||||||
// Check if the user has a discount on the product, or category
|
// Check if the user has a discount on the product, or category
|
||||||
$customer = (new orders_o())->getOrderCustomer($order_id);
|
$customer = (new orders_o())->getOrderCustomer($order_id);
|
||||||
$discount = $customer->getCustomPrice($product_id, false);
|
$discount = $customer->getCustomPrice($product_id, false);
|
||||||
if ($discount) {
|
if ($discount && !products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||||
$price = $price - ($price * $discount / 100);
|
$price = $price - ($price * $discount / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,4 +359,4 @@ class order_items_o extends db
|
|||||||
{
|
{
|
||||||
return (new products_o())->select((int)$this->product_id->value());
|
return (new products_o())->select((int)$this->product_id->value());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1428,7 +1428,8 @@ 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->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
|
||||||
$order_item->reference->set('');
|
$order_item->reference->set('');
|
||||||
// Get the product price based on the department
|
// Get the product price based on the department
|
||||||
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||||
|
$product_price = (int)$priceResolution['price']; // Get the department price for the product
|
||||||
// Get the customers custom price discount percentage
|
// Get the customers custom price discount percentage
|
||||||
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
|
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
|
||||||
if (!$user->exists()) {
|
if (!$user->exists()) {
|
||||||
@@ -1436,7 +1437,9 @@ class orders_o extends db
|
|||||||
}
|
}
|
||||||
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
|
$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
|
// Apply the discount percentage to the product price
|
||||||
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
|
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||||
|
$product_price = (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->notes->set(null); // Set notes for the simulated order item
|
||||||
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
||||||
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
|
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
|
||||||
@@ -1503,10 +1506,14 @@ class orders_o extends db
|
|||||||
if (!$current_user->exists()) {
|
if (!$current_user->exists()) {
|
||||||
throw new Exception('No current user found');
|
throw new Exception('No current user found');
|
||||||
}
|
}
|
||||||
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||||
|
$price = (int)$priceResolution['price']; // Get the department price for the product
|
||||||
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
|
$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
|
||||||
// Apply the discount percentage to the product price
|
// Apply the discount percentage to the product price
|
||||||
|
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||||
|
return $price;
|
||||||
|
}
|
||||||
return (int)round($price * (1 - ($discount_percentage / 100)));
|
return (int)round($price * (1 - ($discount_percentage / 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1589,13 +1596,17 @@ class orders_o extends db
|
|||||||
$product_id = (int)$item['product_id'];
|
$product_id = (int)$item['product_id'];
|
||||||
if (!isset($department_price_cache[$product_id])) {
|
if (!isset($department_price_cache[$product_id])) {
|
||||||
$product = (new products_o())->select($product_id);
|
$product = (new products_o())->select($product_id);
|
||||||
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
|
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
|
||||||
}
|
}
|
||||||
if ($tmp_user === null) {
|
if ($tmp_user === null) {
|
||||||
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||||
}
|
}
|
||||||
$discount = $tmp_user->getCustomPrice($product_id, false);
|
$discount = $tmp_user->getCustomPrice($product_id, false);
|
||||||
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
|
$unitPrice = (int)$department_price_cache[$product_id]['price'];
|
||||||
|
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
|
||||||
|
$unitPrice = (int)round($unitPrice * (1 - ($discount / 100)));
|
||||||
|
}
|
||||||
|
$post_discount = $unitPrice * $quantity;
|
||||||
$total += $post_discount;
|
$total += $post_discount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ class products_o extends db
|
|||||||
|
|
||||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
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
|
* The name of the product
|
||||||
@@ -255,24 +260,41 @@ class products_o extends db
|
|||||||
* @param int $department_id
|
* @param int $department_id
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function applyDepartmentPricing(array $products, int $department_id): array
|
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$department_id = $db->escape_string($department_id);
|
$department_id = $db->escape_string($department_id);
|
||||||
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
|
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
$prices = $db->fetch_all($result);
|
$prices = $db->fetch_all($result);
|
||||||
|
$priceLookup = [];
|
||||||
|
foreach ($prices as $price) {
|
||||||
|
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
|
||||||
foreach ( $products as $key => $product ) {
|
foreach ( $products as $key => $product ) {
|
||||||
foreach ( $prices as $price ) {
|
$productId = (int)($product['id'] ?? 0);
|
||||||
if ((int)$product['id'] === (int)$price['product_id']) {
|
$source = self::PRICE_SOURCE_DEFAULT;
|
||||||
$products[$key]['price'] = $price['price'];
|
if (array_key_exists($productId, $priceLookup)) {
|
||||||
}
|
$products[$key]['price'] = $priceLookup[$productId];
|
||||||
|
$source = self::PRICE_SOURCE_DEPARTMENT;
|
||||||
|
} elseif ($customPricingOnly) {
|
||||||
|
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
|
||||||
|
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includePriceSource) {
|
||||||
|
$products[$key][self::PRICE_SOURCE_KEY] = $source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $products;
|
return $products;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDepartmentPrice(int $department_id): int
|
/**
|
||||||
|
* @return array{price:int,source:string}
|
||||||
|
*/
|
||||||
|
public function getDepartmentPriceResolution(int $department_id): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$department_id = $db->escape_string($department_id);
|
$department_id = $db->escape_string($department_id);
|
||||||
@@ -281,10 +303,27 @@ class products_o extends db
|
|||||||
$prices = $db->fetch_all($result);
|
$prices = $db->fetch_all($result);
|
||||||
// Check if the product has a department price
|
// Check if the product has a department price
|
||||||
if (count($prices) > 0) {
|
if (count($prices) > 0) {
|
||||||
return $prices[0]['price'];
|
return [
|
||||||
|
'price' => (int)$prices[0]['price'],
|
||||||
|
'source' => self::PRICE_SOURCE_DEPARTMENT,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
|
||||||
|
return [
|
||||||
|
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
|
||||||
|
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
// Return the default price
|
// Return the default price
|
||||||
return $this->price->value();
|
return [
|
||||||
|
'price' => (int)$this->price->value(),
|
||||||
|
'source' => self::PRICE_SOURCE_DEFAULT,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDepartmentPrice(int $department_id): int
|
||||||
|
{
|
||||||
|
return $this->getDepartmentPriceResolution($department_id)['price'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
||||||
@@ -303,12 +342,26 @@ class products_o extends db
|
|||||||
// Get the customer's discount percentage
|
// Get the customer's discount percentage
|
||||||
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
|
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
|
||||||
// Apply the discount to the product price
|
// Apply the discount to the product price
|
||||||
if ($discount_percentage > 0) {
|
if ($discount_percentage > 0 && ($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
|
||||||
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
|
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
|
||||||
}
|
}
|
||||||
|
unset($product[self::PRICE_SOURCE_KEY]);
|
||||||
return $product;
|
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
|
public function getSubscriptionMonthlyPrice(): int
|
||||||
{
|
{
|
||||||
// Subscription price (for 2 washes per month) is 1.2 times the normal price
|
// Subscription price (for 2 washes per month) is 1.2 times the normal price
|
||||||
|
|||||||
@@ -13422,7 +13422,6 @@ components:
|
|||||||
- expected
|
- expected
|
||||||
- actual
|
- actual
|
||||||
- data_collection_accepted
|
- data_collection_accepted
|
||||||
- screenshot
|
|
||||||
properties:
|
properties:
|
||||||
before_error:
|
before_error:
|
||||||
type: string
|
type: string
|
||||||
@@ -13438,10 +13437,11 @@ components:
|
|||||||
description: What actually happened
|
description: What actually happened
|
||||||
data_collection_accepted:
|
data_collection_accepted:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Required acceptance of collecting screenshot and diagnostic error data
|
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
|
||||||
screenshot:
|
screenshot:
|
||||||
type: string
|
type: string
|
||||||
description: PNG, JPEG, or WebP data URI of the current app viewport
|
nullable: true
|
||||||
|
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
|
||||||
route_path:
|
route_path:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -13552,6 +13552,7 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
screenshot:
|
screenshot:
|
||||||
type: object
|
type: object
|
||||||
|
nullable: true
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
answers:
|
answers:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute
|
|||||||
$product_cache[$product_id] = (new products_o())->select($product_id);
|
$product_cache[$product_id] = (new products_o())->select($product_id);
|
||||||
}
|
}
|
||||||
if (!isset($department_price_cache[$department_id][$product_id])) {
|
if (!isset($department_price_cache[$department_id][$product_id])) {
|
||||||
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
|
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
|
||||||
}
|
}
|
||||||
if (!array_key_exists($product_id, $discount_cache)) {
|
if (!array_key_exists($product_id, $discount_cache)) {
|
||||||
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
|
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
|
||||||
}
|
}
|
||||||
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
|
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
|
||||||
|
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
|
||||||
|
$unit_price = (int)round($unit_price * (1 - ($discount_cache[$product_id] / 100)));
|
||||||
|
}
|
||||||
|
$post_discount = $unit_price * $quantity;
|
||||||
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
|
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class departmentsRoute
|
|||||||
'economic_department_id',
|
'economic_department_id',
|
||||||
'visible',
|
'visible',
|
||||||
'archived',
|
'archived',
|
||||||
|
'custom_pricing_only',
|
||||||
'longitude',
|
'longitude',
|
||||||
'latitude',
|
'latitude',
|
||||||
])
|
])
|
||||||
@@ -123,6 +124,12 @@ class departmentsRoute
|
|||||||
'latitude' => (float)$department['latitude'],
|
'latitude' => (float)$department['latitude'],
|
||||||
'order_priority' => (int)$department['order_priority'],
|
'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 the user has the permission to view the slack webhook, add it to the response
|
||||||
if ($user->hasPermission('view_slack_webhook')) {
|
if ($user->hasPermission('view_slack_webhook')) {
|
||||||
$tmp_department['slack_webhook'] = $department['slack_webhook'];
|
$tmp_department['slack_webhook'] = $department['slack_webhook'];
|
||||||
@@ -220,6 +227,9 @@ class departmentsRoute
|
|||||||
if (self::isParametersSet(['archived'])) {
|
if (self::isParametersSet(['archived'])) {
|
||||||
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('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();
|
$department->objectChanged();
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
|
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
|
||||||
@@ -240,11 +250,17 @@ class departmentsRoute
|
|||||||
$this->get('/departments/categories', function () {
|
$this->get('/departments/categories', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('list_department_categories');
|
$auth = new authentication();
|
||||||
// Get the user object
|
$user = $auth->get_user();
|
||||||
$user = (new authentication())->get_user();
|
$subuser = $auth->get_subuser();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user || $subuser) {
|
||||||
|
$isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser;
|
||||||
|
if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) {
|
||||||
|
$this->emitForbidden(['list_department_categories']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$responsibleUserId = $user ? (int)$user->id : 0;
|
||||||
// Require the department id
|
// Require the department id
|
||||||
self::requireParameters(['id']);
|
self::requireParameters(['id']);
|
||||||
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
|
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
|
||||||
@@ -253,14 +269,14 @@ class departmentsRoute
|
|||||||
// Validate the department categories object
|
// Validate the department categories object
|
||||||
if (!$department->exists()) {
|
if (!$department->exists()) {
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
||||||
// Return an error
|
// Return an error
|
||||||
$response->error('Department categories not found', 400);
|
$response->error('Department categories not found', 400);
|
||||||
}
|
}
|
||||||
// Get the department categories
|
// Get the department categories
|
||||||
$department_categories = new department_categories_o();
|
$department_categories = new department_categories_o();
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
||||||
// Return the list of department categories
|
// Return the list of department categories
|
||||||
$response->success(
|
$response->success(
|
||||||
$department_categories
|
$department_categories
|
||||||
@@ -285,7 +301,7 @@ class departmentsRoute
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'list_department_categories' => 'List all department categories'
|
'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.'
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -41,18 +41,9 @@ class orderBookingRoute
|
|||||||
$po = self::getTargetPo(); // String | Null
|
$po = self::getTargetPo(); // String | Null
|
||||||
$pickup = self::getTargetPickup(); // Bool | Null
|
$pickup = self::getTargetPickup(); // Bool | Null
|
||||||
$items = self::getTargetItems(); // Array of order_items_o objects
|
$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)$customer_number->customer_number->value(),
|
||||||
(int)$department->id,
|
(int)$department->id
|
||||||
null,
|
|
||||||
'You do not have permission to create this order booking.'
|
|
||||||
);
|
);
|
||||||
/**
|
/**
|
||||||
* Input data
|
* Input data
|
||||||
@@ -96,8 +87,7 @@ class orderBookingRoute
|
|||||||
$response->success($order_bookings_o->asArray());
|
$response->success($order_bookings_o->asArray());
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
|
'add_bookings' => 'Permission to create order bookings for another customer or department scope.'
|
||||||
'add_bookings' => 'Permission to create department order bookings.'
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -659,6 +649,34 @@ class orderBookingRoute
|
|||||||
return $object;
|
return $object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
|
||||||
|
{
|
||||||
|
if ($this->isOrderBookingCustomerSession() && $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.
|
* @throws Exception If the Department is invalid.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -612,21 +612,46 @@ class orderInvoicesRoute
|
|||||||
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
|
$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 = [];
|
$invoice_collection_ids = [];
|
||||||
while ($row = $query_result->fetch_assoc()) {
|
if (self::isParametersSet(['invoice_collection_ids'])) {
|
||||||
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
|
||||||
if ($invoice_collection_id > 0) {
|
if (!is_array($invoice_collection_ids_raw)) {
|
||||||
|
$response->error('invoice_collection_ids must be an array', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
|
||||||
|
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
|
||||||
|
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice_collection_id = (int)$invoice_collection_id_raw;
|
||||||
|
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
|
||||||
|
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||||
|
}
|
||||||
|
|
||||||
$invoice_collection_ids[] = $invoice_collection_id;
|
$invoice_collection_ids[] = $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 = [];
|
$items = [];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\customer_product_rule_service;
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\order_items_o;
|
use objects\order_items_o;
|
||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
@@ -70,6 +71,10 @@ class orderItemsRoute
|
|||||||
$price = (int)self::getParameter('price');
|
$price = (int)self::getParameter('price');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$order = (new orders_o())->getOrderById((int)$data['order_id']);
|
||||||
|
if (!$order->exists()) {
|
||||||
|
$response->error('Order not found', 404);
|
||||||
|
}
|
||||||
$product = (new products_o())->getProductById((int)$data['product_id']);
|
$product = (new products_o())->getProductById((int)$data['product_id']);
|
||||||
if (!$product->exists()) {
|
if (!$product->exists()) {
|
||||||
$response->error('Product not found', 404);
|
$response->error('Product not found', 404);
|
||||||
@@ -77,6 +82,19 @@ class orderItemsRoute
|
|||||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||||
$response->error('Notes is required for this product', 400);
|
$response->error('Notes is required for this product', 400);
|
||||||
}
|
}
|
||||||
|
$customerRuleViolation = (new customer_product_rule_service())
|
||||||
|
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
|
||||||
|
if ($customerRuleViolation !== null) {
|
||||||
|
(new logs_o())->add(
|
||||||
|
'order_items',
|
||||||
|
'global',
|
||||||
|
1,
|
||||||
|
$user->id,
|
||||||
|
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
|
||||||
|
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
|
||||||
|
);
|
||||||
|
$response->error($customerRuleViolation['message'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
|
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
|
||||||
$order_items = (new order_items_o());
|
$order_items = (new order_items_o());
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ class productsRoute
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the category (ID) if the category parameter is provided (In the request 'category')
|
* Get the category (ID) if the category parameter is provided (In the request 'category')
|
||||||
* @return int|null
|
* @return int|null
|
||||||
@@ -91,12 +104,14 @@ class productsRoute
|
|||||||
// Check if the departmentId is set
|
// Check if the departmentId is set
|
||||||
if ($departmentId) {
|
if ($departmentId) {
|
||||||
// Apply the departments unique pricing
|
// Apply the departments unique pricing
|
||||||
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
|
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
|
||||||
}
|
}
|
||||||
// Check if the customer is set
|
// Check if the customer is set
|
||||||
if ($customer !== null) {
|
if ($customer !== null) {
|
||||||
// Apply the customers unique discounts
|
// Apply the customers unique discounts
|
||||||
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
|
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
|
||||||
|
} else {
|
||||||
|
$products = products_o::stripDepartmentPriceSources($products);
|
||||||
}
|
}
|
||||||
return $products;
|
return $products;
|
||||||
}
|
}
|
||||||
@@ -197,6 +212,7 @@ class productsRoute
|
|||||||
// Define the variables
|
// Define the variables
|
||||||
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
$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
|
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||||
|
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||||
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||||
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||||
// Check if the "final_price" parameter is set, and true.
|
// Check if the "final_price" parameter is set, and true.
|
||||||
|
|||||||
@@ -85,6 +85,65 @@ it('previews monthly split changes without moving orders or creating collections
|
|||||||
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
|
->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 {
|
it('splits a selected March and April collected invoice into monthly collections', function (): void {
|
||||||
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
|
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
|
||||||
|
|
||||||
@@ -139,6 +198,74 @@ it('splits a selected March and April collected invoice into monthly collections
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('splits only explicit monthly split invoice collection ids', function (): void {
|
||||||
|
api_test_covers('POST /collected-invoices/split-by-month', 'scope');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'created_at' => '2096-03-01 00:00:01',
|
||||||
|
]);
|
||||||
|
$ignoredCollection = api_fixtures()->createInvoiceCollection([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'created_at' => '2096-03-01 00:00:01',
|
||||||
|
]);
|
||||||
|
$targetMarchOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
|
'created_at' => '2096-03-15 10:00:00',
|
||||||
|
]);
|
||||||
|
$targetAprilOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $targetCollection['id'],
|
||||||
|
'created_at' => '2096-04-02 10:00:00',
|
||||||
|
]);
|
||||||
|
$ignoredMarchOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $ignoredCollection['id'],
|
||||||
|
'created_at' => '2096-03-16 10:00:00',
|
||||||
|
]);
|
||||||
|
$ignoredAprilOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'invoice_collection_id' => $ignoredCollection['id'],
|
||||||
|
'created_at' => '2096-04-03 10:00:00',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||||
|
$createdCollectionIds = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = api_client()->post('/collected-invoices/split-by-month', [
|
||||||
|
'dateFrom' => '2096-03-01',
|
||||||
|
'dateTo' => '2096-04-30',
|
||||||
|
'invoice_collection_ids' => [$targetCollection['id']],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$payload = $response->data();
|
||||||
|
$createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []);
|
||||||
|
$aprilCollectionId = (int)($createdCollectionIds[0] ?? 0);
|
||||||
|
|
||||||
|
expect($payload['processed_count'] ?? null)->toBe(1)
|
||||||
|
->and($payload['changed_count'] ?? null)->toBe(1)
|
||||||
|
->and($aprilCollectionId)->toBeGreaterThan(0)
|
||||||
|
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId)
|
||||||
|
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
|
||||||
|
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
|
||||||
|
} finally {
|
||||||
|
monthly_split_cleanup_collections($createdCollectionIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('sets closed_at to month end when split month has ended', function (): void {
|
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');
|
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
|
||||||
|
|
||||||
@@ -345,3 +472,27 @@ it('rejects invalid monthly split date ranges', function (): void {
|
|||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false);
|
->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,6 +231,7 @@ it('updates departments through the real endpoint', function (): void {
|
|||||||
'description' => 'Updated description',
|
'description' => 'Updated description',
|
||||||
'order_priority' => 5,
|
'order_priority' => 5,
|
||||||
'archived' => true,
|
'archived' => true,
|
||||||
|
'custom_pricing_only' => true,
|
||||||
], $session['headers']);
|
], $session['headers']);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
@@ -246,6 +247,7 @@ it('updates departments through the real endpoint', function (): void {
|
|||||||
expect($row['description'] ?? null)->toBe('Updated description');
|
expect($row['description'] ?? null)->toBe('Updated description');
|
||||||
expect((int)($row['order_priority'] ?? 0))->toBe(5);
|
expect((int)($row['order_priority'] ?? 0))->toBe(5);
|
||||||
expect((int)($row['archived'] ?? 0))->toBe(1);
|
expect((int)($row['archived'] ?? 0))->toBe(1);
|
||||||
|
expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid department update requests', function (): void {
|
it('rejects invalid department update requests', function (): void {
|
||||||
@@ -299,6 +301,42 @@ it('lists department categories for a department', function (): void {
|
|||||||
->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
->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 {
|
it('rejects invalid department category requests', function (): void {
|
||||||
api_test_covers('GET /departments/categories', 'failure');
|
api_test_covers('GET /departments/categories', 'failure');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
function error_report_api_payload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_replace_recursive([
|
||||||
|
'before_error' => 'Opening the orders page',
|
||||||
|
'expected' => 'The orders should load',
|
||||||
|
'actual' => 'The page showed an error',
|
||||||
|
'data_collection_accepted' => true,
|
||||||
|
'data_collection_policy_version' => 'error-report-v1',
|
||||||
|
'route_path' => '/admin/orders',
|
||||||
|
'page_url' => 'https://app.example.test/admin/orders',
|
||||||
|
'release_trace_id' => 'trace-error-report-test',
|
||||||
|
'frontend_version' => 'frontend-test',
|
||||||
|
'api_version' => 'api-test',
|
||||||
|
'request_errors' => [
|
||||||
|
['method' => 'GET', 'url' => '/orders', 'statusCode' => 500],
|
||||||
|
],
|
||||||
|
'vue_errors' => [
|
||||||
|
['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']],
|
||||||
|
],
|
||||||
|
'context' => [
|
||||||
|
'viewport' => ['width' => 1280, 'height' => 720],
|
||||||
|
'user_agent' => 'ErrorReportsApiTest',
|
||||||
|
'captured_at' => '2026-07-06T10:00:00.000Z',
|
||||||
|
'data_collection_policy_version' => 'error-report-v1',
|
||||||
|
],
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function error_report_api_cleanup(array $report): void
|
||||||
|
{
|
||||||
|
$id = (int)($report['id'] ?? 0);
|
||||||
|
if ($id > 0) {
|
||||||
|
api_fixtures()->cleanupDeleteById('error_reports', $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates error reports when screenshot capture failed', function (): void {
|
||||||
|
api_test_covers('POST /error-reports', 'happy');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession();
|
||||||
|
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||||
|
'screenshot' => null,
|
||||||
|
'context' => [
|
||||||
|
'screenshot_attachment' => ['status' => 'capture_failed'],
|
||||||
|
],
|
||||||
|
]), $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(201)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$report = $response->data();
|
||||||
|
expect($report['screenshot'])->toBeNull();
|
||||||
|
expect($report['answers']['before_error'])->toBe('Opening the orders page');
|
||||||
|
expect($report['request_error_count'])->toBe(1);
|
||||||
|
expect($report['vue_error_count'])->toBe(1);
|
||||||
|
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||||
|
'status' => 'capture_failed',
|
||||||
|
'attached' => false,
|
||||||
|
'mime_type' => null,
|
||||||
|
'size_bytes' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
error_report_api_cleanup($report);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates error reports when an optional screenshot payload is invalid', function (): void {
|
||||||
|
api_test_covers('POST /error-reports', 'invalid optional screenshot');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession();
|
||||||
|
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||||
|
'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'),
|
||||||
|
]), $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(201)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$report = $response->data();
|
||||||
|
expect($report['screenshot'])->toBeNull();
|
||||||
|
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||||
|
'status' => 'invalid',
|
||||||
|
'attached' => false,
|
||||||
|
'mime_type' => null,
|
||||||
|
'size_bytes' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
error_report_api_cleanup($report);
|
||||||
|
});
|
||||||
@@ -285,6 +285,85 @@ it('fails price setup gaps without exposing product defaults', function (): void
|
|||||||
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
|
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 {
|
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
|
||||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
|
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?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('lets subusers create 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(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($bookingId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
|
||||||
|
expect($row)->not->toBeNull();
|
||||||
|
expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still requires elevated access for creating another customer order booking', function (): void {
|
||||||
|
api_test_covers('POST /order-bookings', 'auth');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['user']);
|
||||||
|
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']);
|
||||||
|
$product = api_fixtures()->createProduct(['name' => 'Other Booking Product']);
|
||||||
|
|
||||||
|
$response = api_client()->post(
|
||||||
|
'/order-bookings',
|
||||||
|
order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'),
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['add_bookings']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets department-scoped users create order bookings for another customer', function (): void {
|
||||||
|
api_test_covers('POST /order-bookings', 'happy');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']);
|
||||||
|
$department = order_booking_create_department('Department Scoped Booking Department');
|
||||||
|
$product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'add_bookings',
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->post(
|
||||||
|
'/order-bookings',
|
||||||
|
order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'),
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($bookingId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||||
|
});
|
||||||
@@ -4,6 +4,73 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
usesApiSuite();
|
usesApiSuite();
|
||||||
|
|
||||||
|
function create_order_item_rule_fixture(array $customerAttributes = []): array
|
||||||
|
{
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']);
|
||||||
|
foreach ($customerAttributes as $attribute) {
|
||||||
|
api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'RULE-CHECK',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'customer' => $customer,
|
||||||
|
'department' => $department,
|
||||||
|
'order' => $order,
|
||||||
|
'session' => $session,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse
|
||||||
|
{
|
||||||
|
return api_client()->post('/order/items', array_merge([
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $product['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $overrides), $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
|
||||||
|
{
|
||||||
|
$statement = api_test_runtime()->db()->prepare(
|
||||||
|
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
|
||||||
|
VALUES (?, 0, ?, ?)'
|
||||||
|
);
|
||||||
|
$productIdText = (string)$productId;
|
||||||
|
$statement->bind_param('isi', $userId, $productIdText, $percentage);
|
||||||
|
$statement->execute();
|
||||||
|
$statement->close();
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteWhere('price_overrides', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'is_category' => 0,
|
||||||
|
'product_or_category_id' => $productIdText,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function custom_pricing_only_department_price(int $departmentId, int $productId, int $price): void
|
||||||
|
{
|
||||||
|
$statement = api_test_runtime()->db()->prepare(
|
||||||
|
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
|
||||||
|
);
|
||||||
|
$statement->bind_param('iii', $departmentId, $productId, $price);
|
||||||
|
$statement->execute();
|
||||||
|
$statement->close();
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'product_id' => $productId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
|
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
|
||||||
api_test_covers('POST /order/items', 'validation');
|
api_test_covers('POST /order/items', 'validation');
|
||||||
|
|
||||||
@@ -15,7 +82,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
|||||||
'reference' => 'NOTE-REQUIRED',
|
'reference' => 'NOTE-REQUIRED',
|
||||||
]);
|
]);
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902701,
|
|
||||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||||
'price' => 299,
|
'price' => 299,
|
||||||
'requires_note' => 0,
|
'requires_note' => 0,
|
||||||
@@ -49,6 +115,85 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
|||||||
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('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 {
|
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
||||||
api_test_covers('PUT /order/items', 'validation');
|
api_test_covers('PUT /order/items', 'validation');
|
||||||
|
|
||||||
@@ -62,7 +207,6 @@ it('does not allow clearing notes for order items whose product requires notes',
|
|||||||
'reference' => 'NOTE-EDIT',
|
'reference' => 'NOTE-EDIT',
|
||||||
]);
|
]);
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902702,
|
|
||||||
'name' => 'API Note Required Product',
|
'name' => 'API Note Required Product',
|
||||||
'price' => 199,
|
'price' => 199,
|
||||||
'requires_note' => 1,
|
'requires_note' => 1,
|
||||||
@@ -75,7 +219,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
|||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
'notes' => 'Initial note',
|
'notes' => 'Initial note',
|
||||||
]);
|
]);
|
||||||
$session = api_fixtures()->createUserSession(['edit_order_items']);
|
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
|
||||||
|
|
||||||
api_client()
|
api_client()
|
||||||
->put('/order/items', [
|
->put('/order/items', [
|
||||||
@@ -95,7 +239,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
|||||||
api_test_covers('GET /products', 'happy');
|
api_test_covers('GET /products', 'happy');
|
||||||
|
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902703,
|
|
||||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||||
'price' => 299,
|
'price' => 299,
|
||||||
'requires_note' => 0,
|
'requires_note' => 0,
|
||||||
@@ -111,3 +254,202 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
|||||||
|
|
||||||
expect($response->data()['requires_note'] ?? null)->toBeTrue();
|
expect($response->data()['requires_note'] ?? null)->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||||
|
|
||||||
|
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||||
|
$primaryProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Primary truck wash',
|
||||||
|
'price' => 200,
|
||||||
|
]);
|
||||||
|
$addonProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Drying add-on',
|
||||||
|
'category' => 4,
|
||||||
|
'price' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||||
|
'notes' => 'Addon customer rule check',
|
||||||
|
])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||||
|
|
||||||
|
$fixture = create_order_item_rule_fixture();
|
||||||
|
$primaryProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Primary unrestricted truck wash',
|
||||||
|
'price' => 200,
|
||||||
|
]);
|
||||||
|
$addonProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Unrestricted add-on',
|
||||||
|
'category' => 4,
|
||||||
|
'price' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks related addon order items for customers restricted from additional services', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||||
|
|
||||||
|
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']);
|
||||||
|
$primaryProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Primary related truck wash',
|
||||||
|
'price' => 200,
|
||||||
|
]);
|
||||||
|
$addonProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Related extra brush',
|
||||||
|
'price' => 35,
|
||||||
|
]);
|
||||||
|
$primaryItem = api_fixtures()->createOrderItem([
|
||||||
|
'order_id' => $fixture['order']['id'],
|
||||||
|
'product_id' => $primaryProduct['id'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'price' => 200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||||
|
'related_item_id' => $primaryItem['id'],
|
||||||
|
])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||||
|
|
||||||
|
$fixture = create_order_item_rule_fixture([$attribute]);
|
||||||
|
$product = api_fixtures()->createProduct($productAttributes);
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||||
|
})->with([
|
||||||
|
'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]],
|
||||||
|
'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]],
|
||||||
|
'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||||
|
|
||||||
|
$fixture = create_order_item_rule_fixture(['onlyTankCleaning']);
|
||||||
|
$nonTankProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Exterior truck wash',
|
||||||
|
'price' => 180,
|
||||||
|
]);
|
||||||
|
$tankProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Tank cleaning',
|
||||||
|
'category' => 5,
|
||||||
|
'price' => 300,
|
||||||
|
]);
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||||
|
|
||||||
|
post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
|
||||||
|
api_test_covers('GET /products', 'happy');
|
||||||
|
api_test_covers('POST /order/items', 'happy');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Custom Pricing Products',
|
||||||
|
'custom_pricing_only' => 1,
|
||||||
|
]);
|
||||||
|
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
|
||||||
|
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
|
||||||
|
$product = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Custom Pricing Missing Product',
|
||||||
|
'category' => $category['id'],
|
||||||
|
'price' => 12345,
|
||||||
|
]);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||||
|
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
|
||||||
|
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
|
||||||
|
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'list_products',
|
||||||
|
'add_order_items',
|
||||||
|
'department_access_' . (int)$department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$productResponse = api_client()->get(
|
||||||
|
'/products?final_price=true&id=' . (int)$product['id']
|
||||||
|
. '&department_id=' . (int)$department['id']
|
||||||
|
. '&customer_id=' . (int)$customer['customer_number'],
|
||||||
|
$session['headers']
|
||||||
|
);
|
||||||
|
$productResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($productResponse->body)->not->toContain('12345');
|
||||||
|
expect($productResponse->body)->not->toContain('3333');
|
||||||
|
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||||
|
|
||||||
|
api_client()->get(
|
||||||
|
'/products?final_price=true&id=' . (int)$product['id']
|
||||||
|
. '&department_id=' . (int)$otherDepartment['id'],
|
||||||
|
$session['headers']
|
||||||
|
)
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||||
|
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'CUSTOM-ONLY-ORDER',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$orderItem = api_client()->post('/order/items', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $product['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$orderItem
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||||
|
});
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ final class ApiFixtures
|
|||||||
'branding' => (int)($attributes['branding'] ?? 0),
|
'branding' => (int)($attributes['branding'] ?? 0),
|
||||||
'visible' => (int)($attributes['visible'] ?? 1),
|
'visible' => (int)($attributes['visible'] ?? 1),
|
||||||
'archived' => (int)($attributes['archived'] ?? 0),
|
'archived' => (int)($attributes['archived'] ?? 0),
|
||||||
|
'custom_pricing_only' => (int)($attributes['custom_pricing_only'] ?? 0),
|
||||||
'latitude' => $attributes['latitude'] ?? 0.0,
|
'latitude' => $attributes['latitude'] ?? 0.0,
|
||||||
'longitude' => $attributes['longitude'] ?? 0.0,
|
'longitude' => $attributes['longitude'] ?? 0.0,
|
||||||
'order_priority' => (int)($attributes['order_priority'] ?? 0),
|
'order_priority' => (int)($attributes['order_priority'] ?? 0),
|
||||||
@@ -1709,6 +1710,17 @@ final class ApiFixtures
|
|||||||
$this->cleanup->add(fn() => $this->deleteWhere($table, $conditions));
|
$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
|
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
|
||||||
{
|
{
|
||||||
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
|
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS `departments` (
|
|||||||
`branding` INT NULL DEFAULT NULL,
|
`branding` INT NULL DEFAULT NULL,
|
||||||
`visible` TINYINT(1) NOT NULL DEFAULT 1,
|
`visible` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
`archived` TINYINT(1) NOT NULL DEFAULT 0,
|
`archived` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||||
`longitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
`longitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||||
`order_priority` INT NOT NULL DEFAULT 0,
|
`order_priority` INT NOT NULL DEFAULT 0,
|
||||||
@@ -932,6 +933,13 @@ SQL,
|
|||||||
'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)'
|
'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
|
private function ensureOrderInvoiceCollectionSchema(): void
|
||||||
|
|||||||
@@ -76,4 +76,6 @@ it('defines error report schema, routes, permissions, storage, and OpenAPI docs'
|
|||||||
expect($openapi)->toContain('/error-reports:');
|
expect($openapi)->toContain('/error-reports:');
|
||||||
expect($openapi)->toContain('ErrorReportSubmissionRequest');
|
expect($openapi)->toContain('ErrorReportSubmissionRequest');
|
||||||
expect($openapi)->toContain('ErrorReportStatusUpdateRequest');
|
expect($openapi)->toContain('ErrorReportStatusUpdateRequest');
|
||||||
|
expect($openapi)->not->toContain(" - screenshot\n");
|
||||||
|
expect($openapi)->toContain('Reports are accepted without an attachment when capture or upload fails.');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -618,6 +618,33 @@ it('uses a preloaded e-conomic global discount in expected price breakdowns', fu
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the custom-only sentinel without discounts when department price is missing', function (): void {
|
||||||
|
$row = [
|
||||||
|
'customer_number' => 35131752,
|
||||||
|
'user_id' => 411,
|
||||||
|
'product_base_price' => 100,
|
||||||
|
'department_price' => null,
|
||||||
|
'department_custom_pricing_only' => 1,
|
||||||
|
'product_discount_percentage' => 50,
|
||||||
|
'category_discount_percentage' => 25,
|
||||||
|
'apply_category_discount' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
|
||||||
|
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
|
||||||
|
|
||||||
|
expect($expected)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||||
|
expect($breakdown)->toMatchArray([
|
||||||
|
'product_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||||
|
'department_price' => null,
|
||||||
|
'effective_base_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||||
|
'product_discount_percentage' => 50,
|
||||||
|
'category_discount_percentage' => 0,
|
||||||
|
'applied_discount_percentage' => 0,
|
||||||
|
'expected_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
|
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
|
||||||
$row = [
|
$row = [
|
||||||
'customer_number' => 35131752,
|
'customer_number' => 35131752,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use classes\customer_order_product_policy;
|
||||||
|
|
||||||
|
it('recognizes tankcleaning products by category and legacy names', function (): void {
|
||||||
|
expect(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 5,
|
||||||
|
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'category_name' => 'Other',
|
||||||
|
]))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 3,
|
||||||
|
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||||
|
'category_name' => 'Other',
|
||||||
|
]))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 3,
|
||||||
|
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'category_name' => 'Tankrens',
|
||||||
|
]))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects only tankcleaning violations only for attributed customers and non-tank products', function (): void {
|
||||||
|
$washProduct = [
|
||||||
|
'product_category' => 4,
|
||||||
|
'product_name' => 'Forvogn',
|
||||||
|
'category_name' => 'Udvendig',
|
||||||
|
];
|
||||||
|
$tankCleaningProduct = [
|
||||||
|
'product_category' => 5,
|
||||||
|
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||||
|
'category_name' => 'Tank cleaning',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(customer_order_product_policy::onlyTankCleaningViolation(true, $washProduct))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::onlyTankCleaningViolation(true, $tankCleaningProduct))->toBeFalse()
|
||||||
|
->and(customer_order_product_policy::onlyTankCleaningViolation(false, $washProduct))->toBeFalse();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user