From 76dddad410664b3ddd3fb5e85a94883b9abcf642 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 12:01:00 +0200 Subject: [PATCH] Block restricted customer order items --- .../classes/customer_product_rule_service.php | 158 ++++++++++++++++++ services/nginx/app/routes/orderItemsRoute.php | 18 ++ .../nginx/app/tests/Api/OrderItemsApiTest.php | 155 +++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 services/nginx/app/classes/customer_product_rule_service.php diff --git a/services/nginx/app/classes/customer_product_rule_service.php b/services/nginx/app/classes/customer_product_rule_service.php new file mode 100644 index 00000000..e0ed9499 --- /dev/null +++ b/services/nginx/app/classes/customer_product_rule_service.php @@ -0,0 +1,158 @@ +getOrderById($orderId); + if (!$order->exists()) { + return null; + } + + $product = (new products_o())->getProductById($productId); + if (!$product->exists()) { + return null; + } + + $customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value()); + if (!$customer->exists()) { + return null; + } + + $categoryId = (int)$product->category->value(); + $categoryName = $this->categoryName($categoryId); + $searchableProduct = $this->searchableProductText($product, $categoryName); + $isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct); + + if ($customer->doesUserHaveAttribute('restrictAdditionalServices') + && $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) { + return $this->violation('restrictAdditionalServices'); + } + + if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) { + return $this->violation('restrictTankCleaning'); + } + + if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) { + return $this->violation('onlyTankCleaning'); + } + + if ($customer->doesUserHaveAttribute('restrictSpotFree') + && $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) { + return $this->violation('restrictSpotFree'); + } + + if ($customer->doesUserHaveAttribute('restrictInteriorCleaning') + && $this->containsAny($searchableProduct, ['interior', 'indvendig'])) { + return $this->violation('restrictInteriorCleaning'); + } + + return null; + } + + /** + * @return array{rule:string,message:string} + */ + private function violation(string $rule): array + { + return [ + 'rule' => $rule, + 'message' => self::BLOCK_MESSAGE, + ]; + } + + private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool + { + if ($relatedItemId !== null && $relatedItemId > 0) { + return true; + } + + if ($categoryId === self::ADDON_CATEGORY_ID) { + return true; + } + + if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) { + return true; + } + + return $this->countStandaloneOrderItems($orderId) > 0; + } + + private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool + { + if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) { + return true; + } + + return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']); + } + + private function searchableProductText(products_o $product, string $categoryName): string + { + return strtolower(trim((string)$product->name->value() . ' ' . $categoryName)); + } + + /** + * @param array $terms + */ + private function containsAny(string $value, array $terms): bool + { + foreach ($terms as $term) { + if ($term !== '' && str_contains($value, $term)) { + return true; + } + } + + return false; + } + + private function categoryName(int $categoryId): string + { + global $db; + + if ($categoryId <= 0) { + return ''; + } + + $result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1'); + if (!$result || $result->num_rows === 0) { + return ''; + } + + $row = $result->fetch_assoc(); + return strtolower((string)($row['name'] ?? '')); + } + + private function countStandaloneOrderItems(int $orderId): int + { + global $db; + + $result = $db->query( + 'SELECT COUNT(*) AS item_count + FROM order_items + WHERE order_id = ' . $orderId . ' + AND deleted_at IS NULL + AND (related_item_id IS NULL OR related_item_id = 0)' + ); + if (!$result) { + return 0; + } + + $row = $result->fetch_assoc(); + return (int)($row['item_count'] ?? 0); + } +} diff --git a/services/nginx/app/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index ca74179c..8a916180 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\customer_product_rule_service; use objects\logs_o; use objects\order_items_o; use objects\orders_o; @@ -70,6 +71,10 @@ class orderItemsRoute $price = (int)self::getParameter('price'); } } + $order = (new orders_o())->getOrderById((int)$data['order_id']); + if (!$order->exists()) { + $response->error('Order not found', 404); + } $product = (new products_o())->getProductById((int)$data['product_id']); if (!$product->exists()) { $response->error('Product not found', 404); @@ -77,6 +82,19 @@ class orderItemsRoute if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') { $response->error('Notes is required for this product', 400); } + $customerRuleViolation = (new customer_product_rule_service()) + ->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id); + if ($customerRuleViolation !== null) { + (new logs_o())->add( + 'order_items', + 'global', + 1, + $user->id, + 'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE', + 'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule'] + ); + $response->error($customerRuleViolation['message'], 400); + } // Add the order item to the order This is done individually, to make the notes to the individual order items possible $order_items = (new order_items_o()); diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 577c80f6..5d4e72a6 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -4,6 +4,38 @@ declare(strict_types=1); usesApiSuite(); +function create_order_item_rule_fixture(array $customerAttributes = []): array +{ + $customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']); + foreach ($customerAttributes as $attribute) { + api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute); + } + + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'RULE-CHECK', + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + return [ + 'customer' => $customer, + 'department' => $department, + 'order' => $order, + 'session' => $session, + ]; +} + +function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse +{ + return api_client()->post('/order/items', array_merge([ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + ], $overrides), $headers); +} + it('requires notes when adding the extraordinary chemistry product to an order', function (): void { api_test_covers('POST /order/items', 'validation'); @@ -111,3 +143,126 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun expect($response->data()['requires_note'] ?? null)->toBeTrue(); }); + +it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['restrictAdditionalServices']); + $primaryProduct = api_fixtures()->createProduct([ + 'name' => 'Primary truck wash', + 'price' => 200, + ]); + $addonProduct = api_fixtures()->createProduct([ + 'name' => 'Drying add-on', + 'category' => 4, + 'price' => 50, + ]); + + post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); +}); + +it('allows standalone additional order items when the customer is not restricted from additional services', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(); + $primaryProduct = api_fixtures()->createProduct([ + 'name' => 'Primary unrestricted truck wash', + 'price' => 200, + ]); + $addonProduct = api_fixtures()->createProduct([ + 'name' => 'Unrestricted add-on', + 'category' => 4, + 'price' => 50, + ]); + + post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); +}); + +it('blocks related addon order items for customers restricted from additional services', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['restrictAdditionalServices']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']); + $primaryProduct = api_fixtures()->createProduct([ + 'name' => 'Primary related truck wash', + 'price' => 200, + ]); + $addonProduct = api_fixtures()->createProduct([ + 'name' => 'Related extra brush', + 'price' => 35, + ]); + $primaryItem = api_fixtures()->createOrderItem([ + 'order_id' => $fixture['order']['id'], + 'product_id' => $primaryProduct['id'], + 'cashier_id' => $cashier['id'], + 'price' => 200, + ]); + + post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [ + 'related_item_id' => $primaryItem['id'], + ]) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); +}); + +it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture([$attribute]); + $product = api_fixtures()->createProduct($productAttributes); + + post_order_item($fixture['order'], $product, $fixture['session']['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); +})->with([ + 'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]], + 'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]], + 'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]], +]); + +it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['onlyTankCleaning']); + $nonTankProduct = api_fixtures()->createProduct([ + 'name' => 'Exterior truck wash', + 'price' => 180, + ]); + $tankProduct = api_fixtures()->createProduct([ + 'name' => 'Tank cleaning', + 'category' => 5, + 'price' => 300, + ]); + + post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); + + post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); +});