From e1fb79d9b61b289c7b2c950928e4b69dd2a807fd Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Thu, 16 Jul 2026 11:50:52 +0200 Subject: [PATCH] Add customer rule product restrictions --- openapi.yaml | 224 +++++++- .../classes/customer_order_product_policy.php | 72 +-- .../classes/customer_product_rule_service.php | 138 +---- ...e_product_restriction_schema_bootstrap.php | 291 ++++++++++ ...tomer_rule_product_restriction_service.php | 508 ++++++++++++++++++ .../classes/invoice_period_flag_service.php | 97 ++-- services/nginx/app/objects/users_o.php | 73 ++- services/nginx/app/openapi.yaml | 232 +++++++- .../nginx/app/routes/customerAttributes.php | 12 +- services/nginx/app/routes/orderItemsRoute.php | 8 +- ...erCustomerRuleProductRestrictionsRoute.php | 86 +++ .../CollectedInvoiceBulkActionsApiTest.php | 22 + .../tests/Api/CustomerAttributesApiTest.php | 75 +++ ...CustomerRuleProductRestrictionsApiTest.php | 118 ++++ .../nginx/app/tests/Api/OrderItemsApiTest.php | 47 +- .../tests/Support/Api/ApiSchemaBootstrap.php | 52 ++ .../InvoicePeriodFlagServiceTest.php | 24 +- .../Orders/CustomerOrderProductPolicyTest.php | 41 +- ...RuleProductRestrictionArchitectureTest.php | 50 ++ 19 files changed, 1816 insertions(+), 354 deletions(-) create mode 100644 services/nginx/app/classes/customer_rule_product_restriction_schema_bootstrap.php create mode 100644 services/nginx/app/classes/customer_rule_product_restriction_service.php create mode 100644 services/nginx/app/routes/superuserCustomerRuleProductRestrictionsRoute.php create mode 100644 services/nginx/app/tests/Api/CustomerRuleProductRestrictionsApiTest.php create mode 100644 services/nginx/app/tests/Unit/Orders/CustomerRuleProductRestrictionArchitectureTest.php diff --git a/openapi.yaml b/openapi.yaml index 9a1f2f19..84c1844a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: diff --git a/services/nginx/app/classes/customer_order_product_policy.php b/services/nginx/app/classes/customer_order_product_policy.php index cca75373..513dc519 100644 --- a/services/nginx/app/classes/customer_order_product_policy.php +++ b/services/nginx/app/classes/customer_order_product_policy.php @@ -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; - } } diff --git a/services/nginx/app/classes/customer_product_rule_service.php b/services/nginx/app/classes/customer_product_rule_service.php index 1e0da1e8..26b229f2 100644 --- a/services/nginx/app/classes/customer_product_rule_service.php +++ b/services/nginx/app/classes/customer_product_rule_service.php @@ -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,collections:list,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 $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; } } diff --git a/services/nginx/app/classes/customer_rule_product_restriction_schema_bootstrap.php b/services/nginx/app/classes/customer_rule_product_restriction_schema_bootstrap.php new file mode 100644 index 00000000..11d3aa07 --- /dev/null +++ b/services/nginx/app/classes/customer_rule_product_restriction_schema_bootstrap.php @@ -0,0 +1,291 @@ + */ + 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); + } +} diff --git a/services/nginx/app/classes/customer_rule_product_restriction_service.php b/services/nginx/app/classes/customer_rule_product_restriction_service.php new file mode 100644 index 00000000..eeada1c8 --- /dev/null +++ b/services/nginx/app/classes/customer_rule_product_restriction_service.php @@ -0,0 +1,508 @@ +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 */ + public const PRODUCT_IMPACT_ATTRIBUTES = [ + 'restrictAdditionalServices', + 'restrictTankCleaning', + 'restrictSpotFree', + 'restrictInteriorCleaning', + 'onlyTankCleaning', + ]; + + /** @var list */ + 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>,products:list>} */ + public function listConfiguration(): array + { + return [ + 'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES), + 'products' => $this->productCatalog(), + ]; + } + + /** @return array */ + 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 $payload + * @return array + */ + 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> + */ + 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,collections:list,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> $attributes + * @return list> + */ + 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> */ + 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 + */ + 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}> */ + 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 $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; + } +} diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index 152e7769..d0f12bbc 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -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; diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index 1cbb2d9e..ff3466ee 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -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 */ + 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 diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 99563c28..e6042201 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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: diff --git a/services/nginx/app/routes/customerAttributes.php b/services/nginx/app/routes/customerAttributes.php index 274892d8..91920e4e 100644 --- a/services/nginx/app/routes/customerAttributes.php +++ b/services/nginx/app/routes/customerAttributes.php @@ -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); diff --git a/services/nginx/app/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index e3a51fc0..a70954c5 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -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); diff --git a/services/nginx/app/routes/superuserCustomerRuleProductRestrictionsRoute.php b/services/nginx/app/routes/superuserCustomerRuleProductRestrictionsRoute.php new file mode 100644 index 00000000..7923fde7 --- /dev/null +++ b/services/nginx/app/routes/superuserCustomerRuleProductRestrictionsRoute.php @@ -0,0 +1,86 @@ +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 $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; + } +} diff --git a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php index b864899f..2b1c0398 100644 --- a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php +++ b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php @@ -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'], diff --git a/services/nginx/app/tests/Api/CustomerAttributesApiTest.php b/services/nginx/app/tests/Api/CustomerAttributesApiTest.php index dc2b9d7e..c94ecb29 100644 --- a/services/nginx/app/tests/Api/CustomerAttributesApiTest.php +++ b/services/nginx/app/tests/Api/CustomerAttributesApiTest.php @@ -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(); +}); diff --git a/services/nginx/app/tests/Api/CustomerRuleProductRestrictionsApiTest.php b/services/nginx/app/tests/Api/CustomerRuleProductRestrictionsApiTest.php new file mode 100644 index 00000000..740ef264 --- /dev/null +++ b/services/nginx/app/tests/Api/CustomerRuleProductRestrictionsApiTest.php @@ -0,0 +1,118 @@ +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'" + ); + } +}); diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 124c7b68..b19da19f 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -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) diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index bde7920d..231eef82 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -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` ( diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index d6a16ea0..75dbfe1e 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -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 { diff --git a/services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php b/services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php index 8da99e2b..5f4a163d 100644 --- a/services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php +++ b/services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php @@ -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'); }); diff --git a/services/nginx/app/tests/Unit/Orders/CustomerRuleProductRestrictionArchitectureTest.php b/services/nginx/app/tests/Unit/Orders/CustomerRuleProductRestrictionArchitectureTest.php new file mode 100644 index 00000000..265161ee --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/CustomerRuleProductRestrictionArchitectureTest.php @@ -0,0 +1,50 @@ +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'); +});