Add customer rule product restrictions
This commit is contained in:
+211
-13
@@ -3801,13 +3801,19 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrderItemCreate'
|
||||
responses:
|
||||
'201':
|
||||
'200':
|
||||
description: Order item added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
description: Invalid order item or product blocked by an active customer rule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse'
|
||||
- $ref: '#/components/schemas/Error'
|
||||
put:
|
||||
tags:
|
||||
- Order Items
|
||||
@@ -11069,15 +11075,63 @@ paths:
|
||||
$ref: '#/components/schemas/Permission'
|
||||
|
||||
# Customer Management Endpoints
|
||||
/superuser/customer-rules/product-restrictions:
|
||||
get:
|
||||
tags: [Superuser, Users]
|
||||
summary: List global customer-rule product restrictions
|
||||
operationId: listCustomerRuleProductRestrictions
|
||||
responses:
|
||||
'200':
|
||||
description: Rule collections and product catalog retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionListResponse'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/customer-rules/product-restrictions/{attribute}:
|
||||
put:
|
||||
tags: [Superuser, Users]
|
||||
summary: Atomically replace one customer rule's product collections
|
||||
operationId: replaceCustomerRuleProductRestriction
|
||||
parameters:
|
||||
- name: attribute
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerProductImpactAttribute'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Rule configuration replaced
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionResponse'
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'422': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/customer/attributes:
|
||||
get:
|
||||
tags:
|
||||
- Users
|
||||
summary: Get customer attributes
|
||||
description: Get custom attributes for a customer
|
||||
description: Get custom attributes for a customer. Authenticated customer accounts may read their own attributes without list_customer_attributes.
|
||||
operationId: getCustomerAttributes
|
||||
parameters:
|
||||
- name: customer_id
|
||||
- name: customer_number
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: user_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
@@ -11086,7 +11140,8 @@ paths:
|
||||
description: Customer attributes retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAttributesResponse'
|
||||
post:
|
||||
tags:
|
||||
- Users
|
||||
@@ -11094,12 +11149,13 @@ paths:
|
||||
description: Add a custom attribute to a customer
|
||||
operationId: addCustomerAttribute
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAttributeMutationRequest'
|
||||
responses:
|
||||
'201':
|
||||
'200':
|
||||
description: Customer attribute added successfully
|
||||
content:
|
||||
application/json:
|
||||
@@ -11110,11 +11166,17 @@ paths:
|
||||
summary: Delete customer attribute
|
||||
description: Remove a custom attribute from a customer
|
||||
operationId: deleteCustomerAttribute
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
- name: customer_number
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
- name: attribute
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Customer attribute deleted successfully
|
||||
@@ -14052,6 +14114,142 @@ components:
|
||||
type: integer
|
||||
description: HTTP status code
|
||||
|
||||
CustomerProductImpactAttribute:
|
||||
type: string
|
||||
enum: [restrictAdditionalServices, restrictTankCleaning, restrictSpotFree, restrictInteriorCleaning, onlyTankCleaning]
|
||||
|
||||
CustomerRuleProductCollection:
|
||||
type: object
|
||||
required: [id, name, sort_order, product_ids]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string, minLength: 1, maxLength: 191 }
|
||||
sort_order: { type: integer }
|
||||
product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductCollectionInput:
|
||||
type: object
|
||||
required: [name, sort_order, product_ids]
|
||||
properties:
|
||||
id: { type: integer, nullable: true }
|
||||
name: { type: string, minLength: 1, maxLength: 191 }
|
||||
sort_order: { type: integer }
|
||||
product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductRestriction:
|
||||
type: object
|
||||
required: [attribute, version, collections, disabled_product_ids]
|
||||
properties:
|
||||
attribute: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
|
||||
version: { type: integer, minimum: 1 }
|
||||
collections:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProductCollection' }
|
||||
disabled_product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductRestrictionUpdateRequest:
|
||||
type: object
|
||||
required: [version, collections]
|
||||
properties:
|
||||
version: { type: integer, minimum: 1 }
|
||||
collections:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProductCollectionInput' }
|
||||
|
||||
CustomerRuleProduct:
|
||||
type: object
|
||||
required: [id, name, category_id, category_name, active]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
category_id: { type: integer }
|
||||
category_name: { type: string }
|
||||
active: { type: boolean }
|
||||
|
||||
CustomerRuleProductRestrictionListResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data:
|
||||
type: object
|
||||
required: [rules, products]
|
||||
properties:
|
||||
rules:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProductRestriction' }
|
||||
products:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProduct' }
|
||||
|
||||
CustomerRuleProductRestrictionResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data: { $ref: '#/components/schemas/CustomerRuleProductRestriction' }
|
||||
|
||||
CustomerAttribute:
|
||||
type: object
|
||||
required: [id, user_id, attribute, product_restriction]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
user_id: { type: integer }
|
||||
attribute: { type: string }
|
||||
created_at: { type: string, nullable: true }
|
||||
product_restriction:
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/CustomerRuleProductRestriction'
|
||||
|
||||
CustomerAttributesResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerAttribute' }
|
||||
|
||||
CustomerAttributeMutationRequest:
|
||||
type: object
|
||||
required: [attribute]
|
||||
properties:
|
||||
user_id: { type: integer }
|
||||
customer_number: { type: integer }
|
||||
attribute: { type: string }
|
||||
anyOf:
|
||||
- required: [user_id]
|
||||
- required: [customer_number]
|
||||
|
||||
CustomerRuleProductRestrictedResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean, enum: [false] }
|
||||
data:
|
||||
type: object
|
||||
required: [code, message, product_id, rules, collections]
|
||||
properties:
|
||||
code: { type: string, enum: [CUSTOMER_RULE_PRODUCT_RESTRICTED] }
|
||||
message: { type: string }
|
||||
product_id: { type: integer }
|
||||
rules:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
|
||||
collections:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
|
||||
DepartmentCustomerPricingUpdateRequest:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -6,9 +6,6 @@ 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);
|
||||
@@ -19,80 +16,31 @@ class customer_order_product_policy
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$context = self::loadOrderProductContext($orderId, $productId);
|
||||
if ($context === null) {
|
||||
$customerNumber = self::loadOrderCustomerNumber($orderId);
|
||||
if ($customerNumber === 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;
|
||||
$violation = (new customer_rule_product_restriction_service())
|
||||
->violationForCustomerProduct($customerNumber, $productId);
|
||||
return $violation === null ? null : (string)$violation['message'];
|
||||
}
|
||||
|
||||
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
|
||||
private static function loadOrderCustomerNumber(int $orderId): ?int
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1 || $productId < 1) {
|
||||
if ($orderId < 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);
|
||||
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
$customerNumber = (int)($row['customer_id'] ?? 0);
|
||||
return $customerNumber > 0 ? $customerNumber : 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,13 @@
|
||||
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';
|
||||
|
||||
public const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8;
|
||||
private const TANK_CLEANING_CATEGORY_ID = 5;
|
||||
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
private const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES = ['tillægsydelser', 'tillaegsydelser'];
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}|null
|
||||
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
|
||||
*/
|
||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||
{
|
||||
@@ -24,130 +17,17 @@ class customer_product_rule_service
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = (new products_o())->getProductById($productId);
|
||||
if (!$product->exists()) {
|
||||
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
|
||||
(int)$order->customer_id->value(),
|
||||
$productId
|
||||
);
|
||||
if ($violation === null) {
|
||||
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')
|
||||
&& self::isStandaloneAdditionalServiceRow([
|
||||
'related_item_id' => $relatedItemId,
|
||||
'product_category' => $categoryId,
|
||||
'category_name' => $categoryName,
|
||||
])) {
|
||||
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->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isStandaloneAdditionalServiceRow(array $row): bool
|
||||
{
|
||||
if ((int)($row['related_item_id'] ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$categoryId = (int)($row['product_category'] ?? $row['category'] ?? $row['category_id'] ?? 0);
|
||||
if ($categoryId === self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$categoryName = mb_strtolower(trim((string)($row['category_name'] ?? $row['categoryName'] ?? '')));
|
||||
return in_array($categoryName, self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES, true);
|
||||
}
|
||||
|
||||
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 isSpotFreeProduct(int $productId, string $searchableProduct): bool
|
||||
{
|
||||
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
|
||||
}
|
||||
|
||||
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'] ?? ''));
|
||||
// Keep the singular key during the API migration for existing invoice
|
||||
// and logging consumers while also returning every matching rule.
|
||||
return ['rule' => (string)$violation['rules'][0]] + $violation;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Additive schema and the one-time legacy-to-exact-product migration for
|
||||
* customer-rule product restrictions.
|
||||
*/
|
||||
class customer_rule_product_restriction_schema_bootstrap
|
||||
{
|
||||
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const RULES = [
|
||||
'restrictAdditionalServices' => 'Additional services',
|
||||
'restrictTankCleaning' => 'Tank cleaning',
|
||||
'restrictSpotFree' => 'SpotFree',
|
||||
'restrictInteriorCleaning' => 'Interior cleaning',
|
||||
'onlyTankCleaning' => 'Non-tank products',
|
||||
];
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::createTables($db);
|
||||
self::deduplicateCustomerAttributes($db);
|
||||
self::seedLegacyProductSets($db);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function createTables(object $db): void
|
||||
{
|
||||
$statements = [
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (attribute)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_customer_rule_collection_name (attribute, name),
|
||||
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
|
||||
CONSTRAINT fk_customer_rule_collection_attribute
|
||||
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
|
||||
collection_id INT UNSIGNED NOT NULL,
|
||||
product_id INT UNSIGNED NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (collection_id, product_id),
|
||||
KEY idx_customer_rule_collection_product (product_id, collection_id),
|
||||
CONSTRAINT fk_customer_rule_collection_product_collection
|
||||
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
|
||||
migration_key VARCHAR(191) NOT NULL,
|
||||
details_json LONGTEXT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (migration_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
actor_user_id INT UNSIGNED NULL,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
old_version INT UNSIGNED NOT NULL,
|
||||
new_version INT UNSIGNED NOT NULL,
|
||||
changes_json LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
|
||||
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if ($db->query($statement) === false) {
|
||||
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function deduplicateCustomerAttributes(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'customer_attributes')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'DELETE duplicate_row FROM customer_attributes duplicate_row
|
||||
INNER JOIN customer_attributes keep_row
|
||||
ON keep_row.user_id = duplicate_row.user_id
|
||||
AND keep_row.attribute = duplicate_row.attribute
|
||||
AND keep_row.id < duplicate_row.id'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to deduplicate customer attributes');
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'ALTER TABLE customer_attributes
|
||||
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to enforce unique customer attributes');
|
||||
}
|
||||
}
|
||||
|
||||
private static function seedLegacyProductSets(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
|
||||
$existing = $db->query(
|
||||
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
|
||||
);
|
||||
if ($existing && (int)$existing->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new RuntimeException('Unable to start customer-rule product migration');
|
||||
}
|
||||
try {
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
|
||||
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to claim customer-rule product migration');
|
||||
}
|
||||
if (self::affectedRows($db) === 0) {
|
||||
$db->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_keys(self::RULES) as $attribute) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
|
||||
VALUES ('{$safeAttribute}', 1)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to initialize restriction {$attribute}");
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
$seededProductIds = [];
|
||||
foreach (self::RULES as $attribute => $collectionName) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
if ($collectionId < 1) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
|
||||
$predicate = self::legacyPredicate($db, $attribute);
|
||||
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
|
||||
? "(p.deleted_at IS NULL OR p.deleted_at = '')"
|
||||
: '1 = 1';
|
||||
$insert = $db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
SELECT {$collectionId}, p.id
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
WHERE ({$activePredicate}) AND ({$predicate})"
|
||||
);
|
||||
if ($insert === false) {
|
||||
throw new RuntimeException("Unable to seed products for {$attribute}");
|
||||
}
|
||||
$counts[$attribute] = self::affectedRows($db);
|
||||
$seeded = $db->query(
|
||||
"SELECT product_id FROM customer_rule_product_collection_products
|
||||
WHERE collection_id = {$collectionId} ORDER BY product_id"
|
||||
);
|
||||
$seededProductIds[$attribute] = [];
|
||||
if ($seeded) {
|
||||
while ($row = $seeded->fetch_assoc()) {
|
||||
$seededProductIds[$attribute][] = (int)$row['product_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$details = self::escape($db, (string)json_encode([
|
||||
'counts' => $counts,
|
||||
'product_ids' => $seededProductIds,
|
||||
'seeded_at' => gmdate(DATE_ATOM),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_migrations
|
||||
SET details_json = '{$details}', applied_at = NOW()
|
||||
WHERE migration_key = '{$migrationKey}'"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to record customer-rule product migration');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new RuntimeException('Unable to commit customer-rule product migration');
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private static function legacyPredicate(object $db, string $attribute): string
|
||||
{
|
||||
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
|
||||
|
||||
return match ($attribute) {
|
||||
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
|
||||
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
|
||||
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
|
||||
: ''),
|
||||
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
|
||||
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
|
||||
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
|
||||
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
|
||||
default => '0 = 1',
|
||||
};
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$safeTable = self::escape($db, $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeIndex = self::escape($db, $index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = self::escape($db, $column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escape(object $db, string $value): string
|
||||
{
|
||||
return method_exists($db, 'escape_string')
|
||||
? $db->escape_string($value)
|
||||
: addslashes($value);
|
||||
}
|
||||
|
||||
private static function affectedRows(object $db): int
|
||||
{
|
||||
if (method_exists($db, 'conn')) {
|
||||
$connection = $db->conn();
|
||||
return (int)($connection->affected_rows ?? 0);
|
||||
}
|
||||
return (int)($db->affected_rows ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class customer_rule_product_restriction_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->restrictionCode = $code;
|
||||
}
|
||||
|
||||
private string $restrictionCode;
|
||||
|
||||
public function httpStatus(): int
|
||||
{
|
||||
return $this->httpStatus;
|
||||
}
|
||||
|
||||
public function restrictionCode(): string
|
||||
{
|
||||
return $this->restrictionCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for globally configured customer-rule product collections.
|
||||
*/
|
||||
class customer_rule_product_restriction_service
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const PRODUCT_IMPACT_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
public const SUPPORTED_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
'requiresReferenceNumber',
|
||||
'requiresRegistrationNumbersInvoice',
|
||||
'invoiceAllOrdersIndividually',
|
||||
'invoiceWithStripe',
|
||||
'showPricesOnBookingPage',
|
||||
'usePONumbers',
|
||||
'exemptFromAdministrationFee',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
}
|
||||
|
||||
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
|
||||
public function listConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
|
||||
'products' => $this->productCatalog(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function ruleConfiguration(string $attribute): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
global $db;
|
||||
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
|
||||
FROM customer_rule_product_collections c
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE c.attribute = '{$safeAttribute}'
|
||||
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
|
||||
}
|
||||
|
||||
$collections = [];
|
||||
$disabled = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$collectionId = (int)$row['collection_id'];
|
||||
if (!isset($collections[$collectionId])) {
|
||||
$collections[$collectionId] = [
|
||||
'id' => $collectionId,
|
||||
'name' => (string)$row['name'],
|
||||
'sort_order' => (int)$row['sort_order'],
|
||||
'product_ids' => [],
|
||||
];
|
||||
}
|
||||
if ($row['product_id'] !== null) {
|
||||
$productId = (int)$row['product_id'];
|
||||
$collections[$collectionId]['product_ids'][] = $productId;
|
||||
$disabled[$productId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'attribute' => $attribute,
|
||||
'version' => max(1, (int)($versionRow['version'] ?? 1)),
|
||||
'collections' => array_values($collections),
|
||||
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
|
||||
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
|
||||
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
try {
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions
|
||||
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
$currentVersion = (int)$versionRow['version'];
|
||||
if ($currentVersion !== $expectedVersion) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Customer rule configuration has changed; reload before saving',
|
||||
409,
|
||||
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
|
||||
);
|
||||
}
|
||||
|
||||
$old = $this->ruleConfiguration($attribute);
|
||||
$existingIds = $this->existingCollectionIds($attribute);
|
||||
foreach ($collections as $collection) {
|
||||
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
|
||||
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid temporary unique-name collisions while two collections swap names.
|
||||
foreach ($existingIds as $collectionId => $_) {
|
||||
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$temporaryName}'
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($collections as $collection) {
|
||||
$name = $this->escape($collection['name']);
|
||||
$sortOrder = (int)$collection['sort_order'];
|
||||
$collectionId = $collection['id'];
|
||||
if ($collectionId === null) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to create collection');
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
} else {
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$name}', sort_order = {$sortOrder}
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update collection');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds[$collectionId] = true;
|
||||
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to save collection products');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
|
||||
if ($removeIds !== []) {
|
||||
if ($db->query(
|
||||
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
|
||||
implode(',', array_map('intval', $removeIds)) . ')'
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$newVersion = $currentVersion + 1;
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_restrictions
|
||||
SET version = {$newVersion}, updated_at = NOW()
|
||||
WHERE attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
$new = $this->ruleConfiguration($attribute);
|
||||
$changes = $this->escape((string)json_encode([
|
||||
'before' => $old,
|
||||
'after' => $new,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_audit_logs
|
||||
(actor_user_id, attribute, old_version, new_version, changes_json)
|
||||
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
return $new;
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return configured product restrictions for all active product-impact
|
||||
* attributes belonging to any account with the customer number.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function restrictionsForCustomerNumber(int $customerNumber): array
|
||||
{
|
||||
if ($customerNumber < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT DISTINCT ca.attribute
|
||||
FROM users u
|
||||
INNER JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
WHERE u.customer_number = {$customerNumber}"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load active customer-rule product restrictions');
|
||||
}
|
||||
$activeAttributes = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$attribute = (string)$row['attribute'];
|
||||
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$activeAttributes[$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$active = [];
|
||||
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
|
||||
if (isset($activeAttributes[$attribute])) {
|
||||
$active[] = $this->ruleConfiguration($attribute);
|
||||
}
|
||||
}
|
||||
return $active;
|
||||
}
|
||||
|
||||
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
|
||||
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
|
||||
{
|
||||
if ($productId < 1) {
|
||||
return null;
|
||||
}
|
||||
$rules = [];
|
||||
$collections = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
|
||||
continue;
|
||||
}
|
||||
$rules[] = (string)$restriction['attribute'];
|
||||
foreach ($restriction['collections'] as $collection) {
|
||||
if (in_array($productId, $collection['product_ids'], true)) {
|
||||
$collections[] = (int)$collection['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($rules === []) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
|
||||
'message' => customer_product_rule_service::BLOCK_MESSAGE,
|
||||
'product_id' => $productId,
|
||||
'rules' => array_values(array_unique($rules)),
|
||||
'collections' => array_values(array_unique($collections)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $attributes
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function enrichAttributes(int $customerNumber, array $attributes): array
|
||||
{
|
||||
$restrictions = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
$restrictions[(string)$restriction['attribute']] = [
|
||||
'attribute' => (string)$restriction['attribute'],
|
||||
'version' => (int)$restriction['version'],
|
||||
'collections' => $restriction['collections'],
|
||||
'disabled_product_ids' => $restriction['disabled_product_ids'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($attributes as &$attribute) {
|
||||
$key = (string)($attribute['attribute'] ?? '');
|
||||
$attribute['product_restriction'] = $restrictions[$key] ?? null;
|
||||
}
|
||||
unset($attribute);
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function productCatalog(): array
|
||||
{
|
||||
global $db;
|
||||
$activeExpression = $this->columnExists('products', 'deleted_at')
|
||||
? "CASE WHEN p.deleted_at IS NULL OR p.deleted_at = '' THEN 1 ELSE 0 END"
|
||||
: '1';
|
||||
$result = $db->query(
|
||||
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
|
||||
{$activeExpression} AS active
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
ORDER BY c.name ASC, p.name ASC, p.id ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load the customer-rule product catalog');
|
||||
}
|
||||
$products = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$products[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'category_id' => (int)$row['category_id'],
|
||||
'category_name' => (string)($row['category_name'] ?? ''),
|
||||
'active' => (bool)$row['active'],
|
||||
];
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,true>
|
||||
*/
|
||||
private function existingCollectionIds(string $attribute): array
|
||||
{
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load existing collections for {$attribute}");
|
||||
}
|
||||
$ids = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ids[(int)$row['id']] = true;
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
|
||||
private function validateCollections(string $attribute, mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new customer_rule_product_restriction_exception('collections must be an array');
|
||||
}
|
||||
$normalized = [];
|
||||
$names = [];
|
||||
$collectionIds = [];
|
||||
$allProductIds = [];
|
||||
foreach (array_values($value) as $index => $collection) {
|
||||
if (!is_array($collection)) {
|
||||
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
|
||||
}
|
||||
$name = trim((string)($collection['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 191) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
|
||||
}
|
||||
$nameKey = mb_strtolower($name);
|
||||
if (isset($names[$nameKey])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
|
||||
}
|
||||
$names[$nameKey] = true;
|
||||
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
|
||||
throw new customer_rule_product_restriction_exception('product_ids must be an array');
|
||||
}
|
||||
$productIds = [];
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
$id = $this->positiveInt($productId, 'product_id');
|
||||
$productIds[$id] = true;
|
||||
$allProductIds[$id] = true;
|
||||
}
|
||||
$id = isset($collection['id']) && $collection['id'] !== null
|
||||
? $this->positiveInt($collection['id'], 'collection id')
|
||||
: null;
|
||||
if ($id !== null && isset($collectionIds[$id])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
|
||||
}
|
||||
if ($id !== null) {
|
||||
$collectionIds[$id] = true;
|
||||
}
|
||||
$normalized[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
|
||||
? (int)$collection['sort_order']
|
||||
: $index,
|
||||
'product_ids' => array_map('intval', array_keys($productIds)),
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param list<int> $productIds */
|
||||
private function assertProductsExist(array $productIds): void
|
||||
{
|
||||
if ($productIds === []) {
|
||||
return;
|
||||
}
|
||||
global $db;
|
||||
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
|
||||
if (!$result) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Unable to validate collection products',
|
||||
500,
|
||||
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
|
||||
);
|
||||
}
|
||||
$found = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$found[(int)$row['id']] = true;
|
||||
}
|
||||
$missing = array_values(array_diff($productIds, array_keys($found)));
|
||||
if ($missing !== []) {
|
||||
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSupportedAttribute(string $attribute): void
|
||||
{
|
||||
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
private function positiveInt(mixed $value, string $field): int
|
||||
{
|
||||
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
|
||||
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
private function escape(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
global $db;
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = $this->escape($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ class invoice_period_flag_service
|
||||
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
|
||||
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
|
||||
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
|
||||
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
private array $economicCustomerDiscountCache = [];
|
||||
private array $userDisplayNameCache = [];
|
||||
private array $orderItemsPreviewCache = [];
|
||||
@@ -848,11 +847,16 @@ class invoice_period_flag_service
|
||||
{
|
||||
global $db;
|
||||
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
|
||||
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
|
||||
$result = $db->query(
|
||||
"SELECT u.customer_number, ca.attribute
|
||||
"SELECT u.customer_number, ca.attribute, cp.product_id
|
||||
FROM customer_attributes ca
|
||||
JOIN users u ON u.id = ca.user_id
|
||||
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
|
||||
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE 1=1 {$customerFilter}"
|
||||
);
|
||||
|
||||
@@ -863,7 +867,11 @@ class invoice_period_flag_service
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
$attributes[$customerNumber][(string)$row['attribute']] = true;
|
||||
$attribute = (string)$row['attribute'];
|
||||
$attributes[$customerNumber][$attribute] = true;
|
||||
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
@@ -874,6 +882,8 @@ class invoice_period_flag_service
|
||||
$flags = [];
|
||||
$orders = [];
|
||||
$collectionOrders = [];
|
||||
$matchingRules = static fn(int $customerNumber, int $productId): array =>
|
||||
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
@@ -899,57 +909,17 @@ class invoice_period_flag_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
|
||||
&& customer_product_rule_service::isStandaloneAdditionalServiceRow($row)
|
||||
&& (int)($row['item_price'] ?? 0) > 0) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_addon_services',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_only_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
$restrictedProducts = [
|
||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
|
||||
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
|
||||
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
|
||||
$productRuleDefinitions = [
|
||||
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
|
||||
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
|
||||
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
|
||||
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
|
||||
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
|
||||
];
|
||||
|
||||
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
|
||||
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
|
||||
&& $this->rowMatchesProductTerms($row, $terms)) {
|
||||
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
|
||||
if (isset($productRuleDefinitions[$attribute])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
$definitionKey,
|
||||
$productRuleDefinitions[$attribute],
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
@@ -959,6 +929,19 @@ class invoice_period_flag_service
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
|
||||
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_exempt_from_administration_fees',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($orders as $orderId => $row) {
|
||||
@@ -2098,11 +2081,6 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
|
||||
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
@@ -2115,11 +2093,6 @@ class invoice_period_flag_service
|
||||
return false;
|
||||
}
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
{
|
||||
$value = $row['item_include_in_invoice'] ?? 1;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\customer_rule_product_restriction_schema_bootstrap;
|
||||
use classes\customer_name_cache_payload_builder;
|
||||
use classes\object_property;
|
||||
use classes\redis;
|
||||
@@ -268,15 +269,19 @@ class users_o extends db
|
||||
public function addAttribute(string $attribute, ?int $user_id = null): void
|
||||
{
|
||||
global $db;
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
if ($user_id === null) {
|
||||
self::requireSelected();
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$attribute = $db->escape_string($attribute);
|
||||
// To prevent two attributes with the same name for the same user, we will delete the old one if it exists
|
||||
$this->deleteAttribute($attribute, $user_id);
|
||||
$sql = "INSERT INTO customer_attributes (user_id, attribute) VALUES ($user_id, '$attribute')";
|
||||
$db->query($sql);
|
||||
$userIds = $this->attributeSiblingUserIds((int)$user_id);
|
||||
foreach ($userIds as $targetUserId) {
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_attributes (user_id, attribute)
|
||||
VALUES ({$targetUserId}, '{$attribute}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteAttribute(string $attribute, ?int $user_id = null): void
|
||||
@@ -286,29 +291,9 @@ class users_o extends db
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$attribute = $db->escape_string($attribute);
|
||||
// If the user has a customer number, we will delete the attribute from all associated customers
|
||||
$customer_number = $this->customer_number->value();
|
||||
if (!empty($customer_number)) {
|
||||
$sql = "SELECT * FROM users WHERE customer_number = $customer_number";
|
||||
$result = $db->query($sql);
|
||||
$user_ids = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$user_ids[] = (int)$row['id'];
|
||||
}
|
||||
// Delete the attribute from all users
|
||||
foreach ($user_ids as $user_id) {
|
||||
$sql = "DELETE FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
|
||||
$db->query($sql);
|
||||
}
|
||||
return;
|
||||
foreach ($this->attributeSiblingUserIds((int)$user_id) as $targetUserId) {
|
||||
$db->query("DELETE FROM customer_attributes WHERE user_id = {$targetUserId} AND attribute = '{$attribute}'");
|
||||
}
|
||||
// Make sure the attribute exists
|
||||
if (!$this->doesUserHaveAttribute((string)$attribute, (int)$user_id)) {
|
||||
return;
|
||||
}
|
||||
// If the attribute exists, delete it, if it does not exist, nothing will happen
|
||||
$sql = "DELETE FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
|
||||
@@ -318,7 +303,8 @@ class users_o extends db
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$attribute = $db->escape_string($attribute);
|
||||
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
|
||||
$userIds = $this->attributeSiblingUserIds((int)$user_id);
|
||||
$sql = "SELECT id FROM customer_attributes WHERE user_id IN (" . implode(',', $userIds) . ") AND attribute = '$attribute' LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows > 0) {
|
||||
return true;
|
||||
@@ -736,13 +722,44 @@ class users_o extends db
|
||||
if ($user_id === null) {
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id";
|
||||
$userIds = $this->attributeSiblingUserIds((int)$user_id);
|
||||
$sql = "SELECT MIN(id) AS id, MIN(user_id) AS user_id, attribute, MIN(created_at) AS created_at
|
||||
FROM customer_attributes
|
||||
WHERE user_id IN (" . implode(',', $userIds) . ")
|
||||
GROUP BY attribute
|
||||
ORDER BY attribute";
|
||||
$result = $db->query($sql);
|
||||
$array = $db->fetch_all($result);
|
||||
$this->attributes = $array;
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function attributeSiblingUserIds(int $userId): array
|
||||
{
|
||||
global $db;
|
||||
if ($userId < 1) {
|
||||
return [$userId];
|
||||
}
|
||||
$result = $db->query("SELECT customer_number FROM users WHERE id = {$userId} LIMIT 1");
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return [$userId];
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
if ($customerNumber < 1) {
|
||||
return [$userId];
|
||||
}
|
||||
$siblings = $db->query("SELECT id FROM users WHERE customer_number = {$customerNumber}");
|
||||
$ids = [];
|
||||
if ($siblings) {
|
||||
while ($sibling = $siblings->fetch_assoc()) {
|
||||
$ids[] = (int)$sibling['id'];
|
||||
}
|
||||
}
|
||||
return $ids !== [] ? array_values(array_unique($ids)) : [$userId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all discounts for the user (Include)
|
||||
* @return void
|
||||
|
||||
+221
-11
@@ -4130,13 +4130,19 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrderItemCreate'
|
||||
responses:
|
||||
'201':
|
||||
'200':
|
||||
description: Order item added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
description: Invalid order item or product blocked by an active customer rule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse'
|
||||
- $ref: '#/components/schemas/Error'
|
||||
put:
|
||||
tags:
|
||||
- Order Items
|
||||
@@ -11740,6 +11746,50 @@ paths:
|
||||
$ref: '#/components/schemas/Permission'
|
||||
|
||||
# Customer Management Endpoints
|
||||
/superuser/customer-rules/product-restrictions:
|
||||
get:
|
||||
tags: [Superuser, Users]
|
||||
summary: List global customer-rule product restrictions
|
||||
operationId: listCustomerRuleProductRestrictions
|
||||
responses:
|
||||
'200':
|
||||
description: Rule collections and product catalog retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionListResponse'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/customer-rules/product-restrictions/{attribute}:
|
||||
put:
|
||||
tags: [Superuser, Users]
|
||||
summary: Atomically replace one customer rule's product collections
|
||||
operationId: replaceCustomerRuleProductRestriction
|
||||
parameters:
|
||||
- name: attribute
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerProductImpactAttribute'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Rule configuration replaced
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionResponse'
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'422': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/customer/attributes:
|
||||
get:
|
||||
tags:
|
||||
@@ -11752,12 +11802,17 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: user_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Customer attributes retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAttributesResponse'
|
||||
post:
|
||||
tags:
|
||||
- Users
|
||||
@@ -11765,12 +11820,13 @@ paths:
|
||||
description: Add a custom attribute to a customer
|
||||
operationId: addCustomerAttribute
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAttributeMutationRequest'
|
||||
responses:
|
||||
'201':
|
||||
'200':
|
||||
description: Customer attribute added successfully
|
||||
content:
|
||||
application/json:
|
||||
@@ -11781,11 +11837,17 @@ paths:
|
||||
summary: Delete customer attribute
|
||||
description: Remove a custom attribute from a customer
|
||||
operationId: deleteCustomerAttribute
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
- name: customer_number
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
- name: attribute
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Customer attribute deleted successfully
|
||||
@@ -14968,6 +15030,154 @@ components:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
CustomerProductImpactAttribute:
|
||||
type: string
|
||||
enum:
|
||||
- restrictAdditionalServices
|
||||
- restrictTankCleaning
|
||||
- restrictSpotFree
|
||||
- restrictInteriorCleaning
|
||||
- onlyTankCleaning
|
||||
|
||||
CustomerRuleProductCollection:
|
||||
type: object
|
||||
required: [id, name, sort_order, product_ids]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string, minLength: 1, maxLength: 191 }
|
||||
sort_order: { type: integer }
|
||||
product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductRestriction:
|
||||
type: object
|
||||
required: [attribute, version, collections, disabled_product_ids]
|
||||
properties:
|
||||
attribute:
|
||||
$ref: '#/components/schemas/CustomerProductImpactAttribute'
|
||||
version: { type: integer, minimum: 1 }
|
||||
collections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomerRuleProductCollection'
|
||||
disabled_product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductRestrictionUpdateRequest:
|
||||
type: object
|
||||
required: [version, collections]
|
||||
properties:
|
||||
version: { type: integer, minimum: 1 }
|
||||
collections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomerRuleProductCollectionInput'
|
||||
|
||||
CustomerRuleProductCollectionInput:
|
||||
type: object
|
||||
required: [name, sort_order, product_ids]
|
||||
properties:
|
||||
id: { type: integer, nullable: true }
|
||||
name: { type: string, minLength: 1, maxLength: 191 }
|
||||
sort_order: { type: integer }
|
||||
product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProduct:
|
||||
type: object
|
||||
required: [id, name, category_id, category_name, active]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
category_id: { type: integer }
|
||||
category_name: { type: string }
|
||||
active: { type: boolean }
|
||||
|
||||
CustomerRuleProductRestrictionListResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data:
|
||||
type: object
|
||||
required: [rules, products]
|
||||
properties:
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestriction'
|
||||
products:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomerRuleProduct'
|
||||
|
||||
CustomerRuleProductRestrictionResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestriction'
|
||||
|
||||
CustomerAttribute:
|
||||
type: object
|
||||
required: [id, user_id, attribute, product_restriction]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
user_id: { type: integer }
|
||||
attribute: { type: string }
|
||||
created_at: { type: string, nullable: true }
|
||||
product_restriction:
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/CustomerRuleProductRestriction'
|
||||
|
||||
CustomerAttributesResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomerAttribute'
|
||||
|
||||
CustomerAttributeMutationRequest:
|
||||
type: object
|
||||
required: [attribute]
|
||||
properties:
|
||||
user_id: { type: integer }
|
||||
customer_number: { type: integer }
|
||||
attribute: { type: string }
|
||||
anyOf:
|
||||
- required: [user_id]
|
||||
- required: [customer_number]
|
||||
|
||||
CustomerRuleProductRestrictedResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean, enum: [false] }
|
||||
data:
|
||||
type: object
|
||||
required: [code, message, product_id, rules, collections]
|
||||
properties:
|
||||
code: { type: string, enum: [CUSTOMER_RULE_PRODUCT_RESTRICTED] }
|
||||
message: { type: string }
|
||||
product_id: { type: integer }
|
||||
rules:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
|
||||
collections:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
|
||||
SuperuserSecuritySummaryResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\customer_rule_product_restriction_service;
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
@@ -44,9 +45,10 @@ class customerAttributes
|
||||
$actor_id = $user !== false ? (int)$user->id : (int)($subuser->id ?? 0);
|
||||
(new logs_o())->add('customer_attributes', 'global', 1, $actor_id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
|
||||
// Return the list of customer notes
|
||||
$response->success(
|
||||
$response->success((new customer_rule_product_restriction_service())->enrichAttributes(
|
||||
(int)$target_user->customer_number->value(),
|
||||
$target_user->getUserAttributes()
|
||||
);
|
||||
));
|
||||
} else {
|
||||
// Log the incident
|
||||
(new logs_o())->add('customer_attributes', 'global', 1, 0, 'LIST_CUSTOMER_ATTRIBUTES', 'No user found, or invalid session');
|
||||
@@ -76,6 +78,9 @@ class customerAttributes
|
||||
if (!isset($data['attribute'])) {
|
||||
$response->error('Attribute is required', 400);
|
||||
}
|
||||
if (!in_array((string)$data['attribute'], customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES, true)) {
|
||||
$response->error('Unsupported customer attribute', 422);
|
||||
}
|
||||
// Add the note to the customer
|
||||
(new users_o())->automaticGetTargetUserFromRequest()->addAttribute((string)$data['attribute']);
|
||||
// Log the incident
|
||||
@@ -111,6 +116,9 @@ class customerAttributes
|
||||
if (!isset($data['attribute'])) {
|
||||
$response->error('Attribute is required', 400);
|
||||
}
|
||||
if (!in_array((string)$data['attribute'], customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES, true)) {
|
||||
$response->error('Unsupported customer attribute', 422);
|
||||
}
|
||||
// Check if the user exists
|
||||
if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) {
|
||||
$response->error('Customer not found', 400);
|
||||
|
||||
@@ -92,7 +92,13 @@ class orderItemsRoute
|
||||
'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);
|
||||
$response->error([
|
||||
'code' => $customerRuleViolation['code'],
|
||||
'message' => $customerRuleViolation['message'],
|
||||
'product_id' => $customerRuleViolation['product_id'],
|
||||
'rules' => $customerRuleViolation['rules'],
|
||||
'collections' => $customerRuleViolation['collections'],
|
||||
], 400);
|
||||
}
|
||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\customer_rule_product_restriction_exception;
|
||||
use classes\customer_rule_product_restriction_service;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class superuserCustomerRuleProductRestrictionsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/customer-rules/product-restrictions', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserAnyPermission([
|
||||
'superuser_customer_rules_view',
|
||||
'superuser_customer_rules_manage',
|
||||
]);
|
||||
$response->success((new customer_rule_product_restriction_service())->listConfiguration());
|
||||
}, [
|
||||
'superuser_customer_rules_view' => 'View global customer-rule product restrictions',
|
||||
]);
|
||||
|
||||
$this->put('/superuser/customer-rules/product-restrictions/{attribute}', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('superuser_customer_rules_manage');
|
||||
$attribute = trim((string)$this->fromRoute('attribute'));
|
||||
try {
|
||||
$response->success((new customer_rule_product_restriction_service())->replaceRuleConfiguration(
|
||||
$attribute,
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (customer_rule_product_restriction_exception $exception) {
|
||||
$response->error([
|
||||
'code' => $exception->restrictionCode(),
|
||||
'message' => $exception->getMessage(),
|
||||
], $exception->httpStatus());
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[customer_rule_product_restrictions] save failed: ' . $throwable->getMessage());
|
||||
$response->error([
|
||||
'code' => 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED',
|
||||
'message' => 'Unable to save customer rule configuration',
|
||||
], 500);
|
||||
}
|
||||
}, [
|
||||
'superuser_customer_rules_manage' => 'Manage global customer-rule product restrictions',
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireClassicSuperuserPermission(string $permission): bool
|
||||
{
|
||||
global $response;
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage customer rules.', 403);
|
||||
}
|
||||
$this->requirePermission('superuser');
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
/** @param list<string> $permissions */
|
||||
private function requireClassicSuperuserAnyPermission(array $permissions): bool
|
||||
{
|
||||
global $response;
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage customer rules.', 403);
|
||||
}
|
||||
$this->requirePermission('superuser');
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->hasPermission($permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return $this->requirePermission($permissions[0]);
|
||||
}
|
||||
|
||||
private function actorUserId(): int
|
||||
{
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && $user->exists() ? (int)$user->id : 0;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,25 @@ function bulk_action_order_invoice_collection_id(int $orderId): int
|
||||
return (int)($row['invoice_collection_id'] ?? 0);
|
||||
}
|
||||
|
||||
function bulk_action_configure_rule_product(string $attribute, int $productId): void
|
||||
{
|
||||
new \classes\customer_rule_product_restriction_service();
|
||||
$db = api_test_runtime()->db();
|
||||
$safeAttribute = $db->real_escape_string($attribute);
|
||||
$result = $db->query(
|
||||
"SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
|
||||
);
|
||||
$collectionId = (int)$result->fetch_assoc()['id'];
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
|
||||
'collection_id' => $collectionId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
|
||||
@@ -35,6 +54,7 @@ it('previews and applies customer rule cleanup only after exact typed confirmati
|
||||
'name' => 'Spot Free rinse',
|
||||
'price' => 80,
|
||||
]);
|
||||
bulk_action_configure_rule_product('restrictSpotFree', (int)$product['id']);
|
||||
$orderItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
@@ -120,6 +140,8 @@ it('previews and applies customer rule cleanup for both spotfree addon products'
|
||||
'category' => 4,
|
||||
'price' => 39,
|
||||
]);
|
||||
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeVanProduct['id']);
|
||||
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeTruckProduct['id']);
|
||||
$vanOrderItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $spotfreeVanProduct['id'],
|
||||
|
||||
@@ -72,3 +72,78 @@ it('still lets attribute managers read another customer attributes', function ()
|
||||
|
||||
expect($attributes)->toContain('onlyTankCleaning');
|
||||
});
|
||||
|
||||
it('returns exact product restrictions for product-impact attributes and null for workflow attributes', function (): void {
|
||||
api_test_covers('GET /customer/attributes', 'product_restrictions');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['list_customer_attributes']);
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Configured Attribute Customer']);
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'exemptFromAdministrationFee');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Configured exact rinse']);
|
||||
|
||||
new \classes\customer_rule_product_restriction_service();
|
||||
$db = api_test_runtime()->db();
|
||||
$collectionResult = $db->query(
|
||||
"SELECT id FROM customer_rule_product_collections
|
||||
WHERE attribute = 'restrictSpotFree' ORDER BY sort_order, id LIMIT 1"
|
||||
);
|
||||
$collectionId = (int)$collectionResult->fetch_assoc()['id'];
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, " . (int)$product['id'] . ')'
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
|
||||
'collection_id' => $collectionId,
|
||||
'product_id' => (int)$product['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
$response->assertStatus(200)->assertEnvelope()->assertSuccess();
|
||||
|
||||
$byAttribute = [];
|
||||
foreach ($response->data() as $attribute) {
|
||||
$byAttribute[(string)$attribute['attribute']] = $attribute;
|
||||
}
|
||||
expect($byAttribute['restrictSpotFree']['product_restriction']['disabled_product_ids'] ?? [])
|
||||
->toContain((int)$product['id'])
|
||||
->and($byAttribute['restrictSpotFree']['product_restriction']['collections'] ?? [])->not->toBeEmpty()
|
||||
->and($byAttribute['exemptFromAdministrationFee']['product_restriction'] ?? 'missing')->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps workflow-only customer attribute activation compatible', function (): void {
|
||||
api_test_covers('POST /customer/attributes', 'workflow_compatibility');
|
||||
api_test_covers('DELETE /customer/attributes', 'workflow_compatibility');
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'list_customer_attributes',
|
||||
'add_customer_attribute',
|
||||
'delete_customer_attribute',
|
||||
]);
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Workflow Attribute Customer']);
|
||||
|
||||
api_client()->post('/customer/attributes', [
|
||||
'user_id' => (int)$customer['id'],
|
||||
'attribute' => 'exemptFromAdministrationFee',
|
||||
], $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$listed = api_client()->get(
|
||||
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
expect(array_column($listed->data(), 'attribute'))->toContain('exemptFromAdministrationFee');
|
||||
|
||||
api_client()->delete(
|
||||
'/customer/attributes?user_id=' . (int)$customer['id'] . '&attribute=exemptFromAdministrationFee',
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('requires classic superuser view and manage permissions for global rule configuration', function (): void {
|
||||
api_test_covers('GET /superuser/customer-rules/product-restrictions', 'permissions');
|
||||
api_test_covers('PUT /superuser/customer-rules/product-restrictions/{attribute}', 'permissions');
|
||||
|
||||
$withoutView = api_fixtures()->createUserSession(['superuser']);
|
||||
api_client()->get('/superuser/customer-rules/product-restrictions', $withoutView['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['superuser_customer_rules_view']);
|
||||
|
||||
$viewOnly = api_fixtures()->createUserSession(['superuser', 'superuser_customer_rules_view']);
|
||||
$configuration = api_client()->get('/superuser/customer-rules/product-restrictions', $viewOnly['headers']);
|
||||
$configuration->assertStatus(200)->assertEnvelope()->assertSuccess();
|
||||
expect($configuration->data()['rules'] ?? [])->toHaveCount(5)
|
||||
->and($configuration->data()['products'] ?? null)->toBeArray();
|
||||
|
||||
$rule = $configuration->data()['rules'][0];
|
||||
api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/' . $rule['attribute'],
|
||||
['version' => $rule['version'], 'collections' => $rule['collections']],
|
||||
$viewOnly['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['superuser_customer_rules_manage']);
|
||||
|
||||
$manageOnly = api_fixtures()->createUserSession(['superuser', 'superuser_customer_rules_manage']);
|
||||
api_client()->get('/superuser/customer-rules/product-restrictions', $manageOnly['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('atomically replaces collections and rejects stale versions and invalid products', function (): void {
|
||||
api_test_covers('PUT /superuser/customer-rules/product-restrictions/{attribute}', 'versioned_atomic_replace');
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'superuser',
|
||||
'superuser_customer_rules_view',
|
||||
'superuser_customer_rules_manage',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct(['name' => 'Managed exact customer-rule product']);
|
||||
$get = api_client()->get('/superuser/customer-rules/product-restrictions', $session['headers']);
|
||||
$rules = $get->data()['rules'];
|
||||
$rule = array_values(array_filter(
|
||||
$rules,
|
||||
static fn(array $candidate): bool => $candidate['attribute'] === 'restrictInteriorCleaning'
|
||||
))[0];
|
||||
$collectionName = 'API managed ' . bin2hex(random_bytes(5));
|
||||
$collections = $rule['collections'];
|
||||
$collections[] = [
|
||||
'name' => $collectionName,
|
||||
'sort_order' => 999,
|
||||
'product_ids' => [(int)$product['id']],
|
||||
];
|
||||
|
||||
$saved = api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
|
||||
['version' => $rule['version'], 'collections' => $collections],
|
||||
$session['headers']
|
||||
);
|
||||
$saved->assertStatus(200)->assertEnvelope()->assertSuccess();
|
||||
expect($saved->data()['version'])->toBe((int)$rule['version'] + 1)
|
||||
->and($saved->data()['disabled_product_ids'])->toContain((int)$product['id']);
|
||||
|
||||
$managedCollection = array_values(array_filter(
|
||||
$saved->data()['collections'],
|
||||
static fn(array $collection): bool => $collection['name'] === $collectionName
|
||||
))[0];
|
||||
$collectionId = (int)$managedCollection['id'];
|
||||
|
||||
try {
|
||||
api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
|
||||
['version' => $rule['version'], 'collections' => $collections],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(409)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Customer rule configuration has changed; reload before saving');
|
||||
|
||||
$invalidCollections = $saved->data()['collections'];
|
||||
$invalidCollections[0]['product_ids'][] = 2147483647;
|
||||
api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
|
||||
['version' => $saved->data()['version'], 'collections' => $invalidCollections],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(422)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
$unchanged = api_client()->get('/superuser/customer-rules/product-restrictions', $session['headers']);
|
||||
$current = array_values(array_filter(
|
||||
$unchanged->data()['rules'],
|
||||
static fn(array $candidate): bool => $candidate['attribute'] === 'restrictInteriorCleaning'
|
||||
))[0];
|
||||
expect($current['version'])->toBe($saved->data()['version'])
|
||||
->and($current['disabled_product_ids'])->not->toContain(2147483647);
|
||||
} finally {
|
||||
$db = api_test_runtime()->db();
|
||||
$db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}");
|
||||
$db->query("DELETE FROM customer_rule_product_collections WHERE id = {$collectionId}");
|
||||
$db->query(
|
||||
"UPDATE customer_rule_product_restrictions SET version = " . (int)$rule['version'] .
|
||||
" WHERE attribute = 'restrictInteriorCleaning'"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -36,6 +36,33 @@ function post_order_item(array $order, array $product, array $headers, array $ov
|
||||
], $overrides), $headers);
|
||||
}
|
||||
|
||||
function configure_customer_rule_product(string $attribute, int $productId): void
|
||||
{
|
||||
new \classes\customer_rule_product_restriction_service();
|
||||
$db = api_test_runtime()->db();
|
||||
$safeAttribute = $db->real_escape_string($attribute);
|
||||
$result = $db->query(
|
||||
"SELECT id FROM customer_rule_product_collections
|
||||
WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
|
||||
);
|
||||
$collectionId = $result && $result->num_rows > 0 ? (int)$result->fetch_assoc()['id'] : 0;
|
||||
if ($collectionId < 1) {
|
||||
$db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', 'API exact restriction', 0)"
|
||||
);
|
||||
$collectionId = (int)$db->insert_id;
|
||||
}
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
|
||||
'collection_id' => $collectionId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
@@ -198,6 +225,7 @@ it('only allows tankcleaning products for only tankcleaning customers', function
|
||||
'category' => 5,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
configure_customer_rule_product('onlyTankCleaning', (int)$washProduct['id']);
|
||||
|
||||
api_client()
|
||||
->post('/order/items', [
|
||||
@@ -330,6 +358,7 @@ it('blocks standalone category 8 products for customers restricted from addition
|
||||
'category' => 8,
|
||||
'price' => 50,
|
||||
]);
|
||||
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
@@ -370,7 +399,7 @@ it('allows standalone category 8 products when the customer is not restricted fr
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('allows related addon order items for customers restricted from additional services', function (): void {
|
||||
it('blocks configured 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']);
|
||||
@@ -390,13 +419,15 @@ it('allows related addon order items for customers restricted from additional se
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 200,
|
||||
]);
|
||||
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('still blocks related add-ons covered by a specific customer product rule', function (): void {
|
||||
@@ -412,6 +443,7 @@ it('still blocks related add-ons covered by a specific customer product rule', f
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 200,
|
||||
]);
|
||||
configure_customer_rule_product('restrictInteriorCleaning', (int)$interiorProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $interiorProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
@@ -448,7 +480,7 @@ it('does not infer additional-service restrictions from category 4, product name
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('uses the exact Tillægsydelser category name as the legacy additional-service fallback', function (): void {
|
||||
it('does not infer additional-service restrictions from the legacy Tillægsydelser category after migration', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
@@ -460,10 +492,9 @@ it('uses the exact Tillægsydelser category name as the legacy additional-servic
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('validates that related order items exist, are active, and belong to the target order', function (): void {
|
||||
@@ -530,6 +561,7 @@ it('blocks named restricted service products for the selected customer', functio
|
||||
|
||||
$fixture = create_order_item_rule_fixture([$attribute]);
|
||||
$product = api_fixtures()->createProduct($productAttributes);
|
||||
configure_customer_rule_product($attribute, (int)$product['id']);
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
@@ -555,6 +587,7 @@ it('only allows tank cleaning products when the customer has the only tank clean
|
||||
'category' => 5,
|
||||
'price' => 300,
|
||||
]);
|
||||
configure_customer_rule_product('onlyTankCleaning', (int)$nonTankProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
|
||||
@@ -899,6 +899,58 @@ CREATE TABLE IF NOT EXISTS `customer_attributes` (
|
||||
KEY `idx_customer_attributes_attribute` (`attribute`),
|
||||
KEY `idx_customer_attributes_attribute_user` (`attribute`, `user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'customer_rule_product_restrictions' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `customer_rule_product_restrictions` (
|
||||
`attribute` VARCHAR(191) NOT NULL,
|
||||
`version` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`attribute`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'customer_rule_product_collections' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `customer_rule_product_collections` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`attribute` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_customer_rule_collection_name` (`attribute`, `name`),
|
||||
KEY `idx_customer_rule_collection_attribute_order` (`attribute`, `sort_order`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'customer_rule_product_collection_products' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `customer_rule_product_collection_products` (
|
||||
`collection_id` INT UNSIGNED NOT NULL,
|
||||
`product_id` INT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`collection_id`, `product_id`),
|
||||
KEY `idx_customer_rule_collection_product` (`product_id`, `collection_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'customer_rule_product_migrations' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `customer_rule_product_migrations` (
|
||||
`migration_key` VARCHAR(191) NOT NULL,
|
||||
`details_json` LONGTEXT NULL,
|
||||
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`migration_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'customer_rule_product_audit_logs' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `customer_rule_product_audit_logs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`actor_user_id` INT UNSIGNED NULL,
|
||||
`attribute` VARCHAR(191) NOT NULL,
|
||||
`old_version` INT UNSIGNED NOT NULL,
|
||||
`new_version` INT UNSIGNED NOT NULL,
|
||||
`changes_json` LONGTEXT NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_customer_rule_product_audit_attribute` (`attribute`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'module_config' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `module_config` (
|
||||
|
||||
@@ -316,7 +316,10 @@ it('allows tank cleaning products for only tank cleaning customers', function ()
|
||||
|
||||
$onlyTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
|
||||
[$tankCleaningRow, $tankCleaningAddonRow, $washRow],
|
||||
[424242 => ['onlyTankCleaning' => true]],
|
||||
[424242 => [
|
||||
'onlyTankCleaning' => true,
|
||||
'__disabled_products' => [3 => ['onlyTankCleaning' => true]],
|
||||
]],
|
||||
]);
|
||||
|
||||
expect(array_column($onlyTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_only_tank_cleaning']);
|
||||
@@ -324,14 +327,17 @@ it('allows tank cleaning products for only tank cleaning customers', function ()
|
||||
|
||||
$restrictedTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
|
||||
[$tankCleaningRow, $washRow],
|
||||
[424242 => ['restrictTankCleaning' => true]],
|
||||
[424242 => [
|
||||
'restrictTankCleaning' => true,
|
||||
'__disabled_products' => [30 => ['restrictTankCleaning' => true]],
|
||||
]],
|
||||
]);
|
||||
|
||||
expect(array_column($restrictedTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_restrict_tank_cleaning']);
|
||||
expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801);
|
||||
});
|
||||
|
||||
it('flags only standalone Tillægsydelser items for the additional-services customer rule', function (): void {
|
||||
it('flags every exactly configured product regardless of standalone or addon context', function (): void {
|
||||
$baseRow = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Additional Services Customer',
|
||||
@@ -378,14 +384,22 @@ it('flags only standalone Tillægsydelser items for the additional-services cust
|
||||
|
||||
$flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
|
||||
[$standaloneCategoryEight, $relatedCategoryEight, $namedCategoryFourAddon, $legacyCategoryName],
|
||||
[424242 => ['restrictAdditionalServices' => true]],
|
||||
[424242 => [
|
||||
'restrictAdditionalServices' => true,
|
||||
'__disabled_products' => [
|
||||
91 => ['restrictAdditionalServices' => true],
|
||||
71 => ['restrictAdditionalServices' => true],
|
||||
94 => ['restrictAdditionalServices' => true],
|
||||
],
|
||||
]],
|
||||
]);
|
||||
|
||||
expect(array_column($flags, 'definition_key'))->toBe([
|
||||
'customer_rule_restrict_addon_services',
|
||||
'customer_rule_restrict_addon_services',
|
||||
'customer_rule_restrict_addon_services',
|
||||
]);
|
||||
expect(array_column($flags, 'target_id'))->toBe([811, 814]);
|
||||
expect(array_column($flags, 'target_id'))->toBe([811, 812, 814]);
|
||||
});
|
||||
|
||||
it('does not flag interior wash variants as historical primary product mismatches', function (): void {
|
||||
|
||||
@@ -2,39 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\customer_order_product_policy;
|
||||
it('delegates every lower-level order product decision to the exact configured restriction service', function (): void {
|
||||
$policy = file_get_contents(app_path('classes/customer_order_product_policy.php'));
|
||||
|
||||
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();
|
||||
expect($policy)
|
||||
->toContain('violationForCustomerProduct($customerNumber, $productId)')
|
||||
->not->toContain('product_category')
|
||||
->not->toContain('tankcleaning')
|
||||
->not->toContain('rowMatchesProductTerms');
|
||||
});
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
it('defines idempotent normalized product restriction schema and one-time exact legacy seeding', function (): void {
|
||||
$schema = file_get_contents(app_path('classes/customer_rule_product_restriction_schema_bootstrap.php'));
|
||||
|
||||
expect($schema)
|
||||
->toContain('CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions')
|
||||
->toContain('CREATE TABLE IF NOT EXISTS customer_rule_product_collections')
|
||||
->toContain('CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products')
|
||||
->toContain('legacy_exact_product_sets_v1')
|
||||
->toContain('customer_rule_product_migrations')
|
||||
->toContain('po.option_id = p.id')
|
||||
->toContain('Unable to record customer-rule product migration');
|
||||
});
|
||||
|
||||
it('uses configured exact product ids in runtime order and invoice enforcement', function (): void {
|
||||
$productRules = file_get_contents(app_path('classes/customer_product_rule_service.php'));
|
||||
$policy = file_get_contents(app_path('classes/customer_order_product_policy.php'));
|
||||
$invoice = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
|
||||
|
||||
expect($productRules)->toContain('violationForCustomerProduct')
|
||||
->and($policy)->toContain('violationForCustomerProduct')
|
||||
->and($invoice)->toContain("['__disabled_products']")
|
||||
->and($invoice)->not->toContain('customer_product_rule_service::isStandaloneAdditionalServiceRow($row)')
|
||||
->and($invoice)->not->toContain('$this->rowIsTankCleaningProduct($row)');
|
||||
});
|
||||
|
||||
it('wires versioned management permissions and structured customer attributes', function (): void {
|
||||
$route = file_get_contents(app_path('routes/superuserCustomerRuleProductRestrictionsRoute.php'));
|
||||
$attributes = file_get_contents(app_path('routes/customerAttributes.php'));
|
||||
|
||||
expect($route)
|
||||
->toContain('/superuser/customer-rules/product-restrictions')
|
||||
->toContain('requireClassicSuperuserAnyPermission([')
|
||||
->toContain("'superuser_customer_rules_view'")
|
||||
->toContain("requireClassicSuperuserPermission('superuser_customer_rules_manage')")
|
||||
->and($attributes)->toContain('enrichAttributes(')
|
||||
->and($attributes)->toContain('SUPPORTED_ATTRIBUTES');
|
||||
});
|
||||
|
||||
it('fails closed when configured restriction reads cannot be completed', function (): void {
|
||||
$service = file_get_contents(app_path('classes/customer_rule_product_restriction_service.php'));
|
||||
|
||||
expect($service)
|
||||
->toContain('Unable to load customer-rule restriction version')
|
||||
->toContain('Unable to load customer-rule restriction collections')
|
||||
->toContain('Unable to load active customer-rule product restrictions')
|
||||
->toContain('foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute)')
|
||||
->toContain('Unable to validate collection products');
|
||||
});
|
||||
Reference in New Issue
Block a user