From d47ea1d6596fca0951e4e479fd0ac9b667376eea Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 10:15:11 +0200 Subject: [PATCH 01/14] Add custom-only department pricing enforcement --- .../classes/departments_schema_bootstrap.php | 8 ++ .../classes/invoice_period_flag_service.php | 22 +++- .../classes/limited_backoffice_service.php | 28 +++-- services/nginx/app/objects/departments_o.php | 8 ++ services/nginx/app/objects/order_items_o.php | 8 +- services/nginx/app/objects/orders_o.php | 21 +++- services/nginx/app/objects/products_o.php | 71 ++++++++++-- .../nginx/app/routes/InvoicingPeriodRoute.php | 8 +- .../nginx/app/routes/departmentsRoute.php | 10 ++ services/nginx/app/routes/productsRoute.php | 18 ++- .../app/tests/Api/DepartmentsApiTest.php | 2 + .../tests/Api/LimitedBackofficeApiTest.php | 79 +++++++++++++ .../nginx/app/tests/Api/OrderItemsApiTest.php | 108 ++++++++++++++++++ .../app/tests/Support/Api/ApiFixtures.php | 1 + .../tests/Support/Api/ApiSchemaBootstrap.php | 8 ++ .../InvoicePeriodFlagServiceTest.php | 27 +++++ 16 files changed, 393 insertions(+), 34 deletions(-) diff --git a/services/nginx/app/classes/departments_schema_bootstrap.php b/services/nginx/app/classes/departments_schema_bootstrap.php index c6e0eed6..89da46eb 100644 --- a/services/nginx/app/classes/departments_schema_bootstrap.php +++ b/services/nginx/app/classes/departments_schema_bootstrap.php @@ -34,6 +34,14 @@ class departments_schema_bootstrap ); } + if (!self::columnExists($db, 'departments', 'custom_pricing_only')) { + $db->query( + "ALTER TABLE departments + ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0 + AFTER archived" + ); + } + if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) { $db->query( "ALTER TABLE departments diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index bb8e0a56..4968a373 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -688,6 +688,7 @@ class invoice_period_flag_service o.po AS order_po, o.notes AS order_notes, o.department_id, + d.custom_pricing_only AS department_custom_pricing_only, o.reg_1, o.invoice_collection_id, o.wash_id, @@ -720,6 +721,7 @@ class invoice_period_flag_service GROUP BY customer_number ) u ON u.customer_number = o.customer_id LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + LEFT JOIN departments d ON d.id = o.department_id LEFT JOIN products p ON p.id = oi.product_id LEFT JOIN categories c ON c.id = p.category LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id @@ -1921,19 +1923,26 @@ class invoice_period_flag_service private function calculateExpectedPrice(array $row): int { - $base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0); - $discount = $this->discountBreakdown($row)['applied_discount_percentage']; + $customMissingPrice = $this->isCustomMissingDepartmentPrice($row); + $base = $row['department_price'] !== null + ? (int)$row['department_price'] + : ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0)); + $discount = $customMissingPrice ? 0 : $this->discountBreakdown($row)['applied_discount_percentage']; return (int)round($base * (1 - ($discount / 100))); } private function priceBreakdown(array $row, int $expected): array { $departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null; - $base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0); + $customMissingPrice = $this->isCustomMissingDepartmentPrice($row); + $base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0)); $discount = $this->discountBreakdown($row); + if ($customMissingPrice) { + $discount['applied_discount_percentage'] = 0; + } return [ - 'product_price' => (int)($row['product_base_price'] ?? 0), + 'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0), 'department_price' => $departmentPrice, 'effective_base_price' => $base, 'product_discount_percentage' => $discount['product_discount_percentage'], @@ -1944,6 +1953,11 @@ class invoice_period_flag_service ]; } + private function isCustomMissingDepartmentPrice(array $row): bool + { + return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0); + } + private function discountBreakdown(array $row): array { $productDiscount = (int)($row['product_discount_percentage'] ?? 0); diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index d8ecd4c4..fc5233b3 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -3,6 +3,7 @@ namespace classes; use mysqli; +use objects\products_o; use objects\users_o; class limited_backoffice_service @@ -119,6 +120,7 @@ class limited_backoffice_service public function __construct() { + departments_schema_bootstrap::ensureTables(); limited_backoffice_schema_bootstrap::ensureTables(); } @@ -194,7 +196,7 @@ class limited_backoffice_service $in = implode(',', array_map('intval', $departmentIds)); $sql = " - SELECT `id`, `name`, `description`, `visible`, `archived` + SELECT `id`, `name`, `description`, `visible`, `archived`, `custom_pricing_only` FROM `departments` WHERE `id` IN ($in) ORDER BY `order_priority` ASC, `name` ASC, `id` ASC @@ -209,6 +211,7 @@ class limited_backoffice_service 'description' => (string)($row['description'] ?? ''), 'visible' => (bool)($row['visible'] ?? false), 'archived' => (bool)($row['archived'] ?? false), + 'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0), ], $rows); } @@ -224,8 +227,9 @@ class limited_backoffice_service throw new limited_backoffice_exception('Department not found', 404); } - $catalog = $this->departmentProductCatalog($departmentId); - if ($catalog['missing_products'] !== []) { + $customPricingOnly = (bool)($department['custom_pricing_only'] ?? false); + $catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly); + if (!$customPricingOnly && $catalog['missing_products'] !== []) { throw new limited_backoffice_exception('Department price setup is incomplete.', 409, [ 'message' => 'Department price setup is incomplete.', 'code' => 'department_price_setup_required', @@ -257,7 +261,8 @@ class limited_backoffice_service throw new limited_backoffice_exception('Department not found', 404); } - $catalog = $this->departmentProductCatalog($departmentId); + $customPricingOnly = (bool)($department['custom_pricing_only'] ?? false); + $catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly); if ($catalog['required_product_ids'] === []) { throw new limited_backoffice_exception('Department has no products configured.', 409); } @@ -274,7 +279,7 @@ class limited_backoffice_service sort($providedProductIds); $missingProductIds = array_values(array_diff($requiredProductIds, $providedProductIds)); - if ($missingProductIds !== []) { + if (!$customPricingOnly && $missingProductIds !== []) { throw new limited_backoffice_exception('Price is required for every department product.', 400, [ 'message' => 'Price is required for every department product.', 'missing_product_ids' => $missingProductIds, @@ -606,13 +611,13 @@ class limited_backoffice_service } /** - * @return array{id:int,name:string,description:string} + * @return array{id:int,name:string,description:string,custom_pricing_only:bool} */ private function fetchDepartment(int $departmentId): ?array { global $db; $statement = $this->mysqli()->prepare( - 'SELECT `id`, `name`, `description` FROM `departments` WHERE `id` = ? LIMIT 1' + 'SELECT `id`, `name`, `description`, `custom_pricing_only` FROM `departments` WHERE `id` = ? LIMIT 1' ); if ($statement === false) { throw new limited_backoffice_exception('Unable to load department.', 500); @@ -631,13 +636,14 @@ class limited_backoffice_service 'id' => (int)$row['id'], 'name' => (string)$row['name'], 'description' => (string)($row['description'] ?? ''), + 'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0), ]; } /** * @return array{categories:array>,missing_products:array>,required_product_ids:array} */ - private function departmentProductCatalog(int $departmentId): array + private function departmentProductCatalog(int $departmentId, bool $customPricingOnly = false): array { global $db; @@ -698,10 +704,12 @@ class limited_backoffice_service 'id' => $productId, 'name' => (string)$row['product_name'], 'description' => (string)($row['product_description'] ?? ''), - 'price' => $row['department_price'] === null ? null : (int)$row['department_price'], + 'price' => $row['department_price'] === null + ? ($customPricingOnly ? products_o::CUSTOM_PRICING_MISSING_PRICE : null) + : (int)$row['department_price'], ]; - if ($row['department_price_id'] === null) { + if ($row['department_price_id'] === null && !$customPricingOnly) { $missing[] = [ 'id' => $productId, 'name' => (string)$row['product_name'], diff --git a/services/nginx/app/objects/departments_o.php b/services/nginx/app/objects/departments_o.php index 1ef01c76..0493ae86 100644 --- a/services/nginx/app/objects/departments_o.php +++ b/services/nginx/app/objects/departments_o.php @@ -22,6 +22,7 @@ class departments_o extends db public object_property $dimension; // The dimension of the department public object_property $visible; // The visibility of the department public object_property $archived; // Whether the department is archived + public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults public object_property $branding; // The branding of the department public object_property $longitude; // The longitude of the department (Can be null) public object_property $latitude; // The latitude of the department (Can be null) @@ -107,6 +108,7 @@ class departments_o extends db $this->branding = new object_property($this->table, $this->id, 'branding', 'int', false); $this->visible = new object_property($this->table, $this->id, 'visible', 'int', false); $this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false); + $this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false); $this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false); $this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); @@ -185,6 +187,12 @@ class departments_o extends db return $department; } + public function isCustomPricingOnly(int $department_id): bool + { + $department = $this->getDepartmentById($department_id); + return (bool)(int)($department['custom_pricing_only'] ?? 0); + } + /** * Get the price of a product in a department * @param int $department_id diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index 38a43603..ef087613 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -168,12 +168,14 @@ class order_items_o extends db // Get the order $order = (new orders_o())->getOrderById($order_id); // Get the product price - $price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value()); + $product = (new products_o())->getProductById($product_id); + $priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value()); + $price = $priceResolution['price']; // Check if the user has a discount on the product, or category $customer = (new orders_o())->getOrderCustomer($order_id); $discount = $customer->getCustomPrice($product_id, false); - if ($discount) { + if ($discount && !products_o::priceResolutionIsCustomMissing($priceResolution)) { $price = $price - ($price * $discount / 100); } @@ -354,4 +356,4 @@ class order_items_o extends db { return (new products_o())->select((int)$this->product_id->value()); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 878d2110..5cef7332 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1428,7 +1428,8 @@ class orders_o extends db $order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item $order_item->reference->set(''); // Get the product price based on the department - $product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product + $priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value()); + $product_price = (int)$priceResolution['price']; // Get the department price for the product // Get the customers custom price discount percentage $user = $xlvask_usage_log->getUser(); // Get the user from the usage log if (!$user->exists()) { @@ -1436,7 +1437,9 @@ class orders_o extends db } $product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product // Apply the discount percentage to the product price - $product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price + if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { + $product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price + } $order_item->notes->set(null); // Set notes for the simulated order item $order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage $order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item @@ -1503,10 +1506,14 @@ class orders_o extends db if (!$current_user->exists()) { throw new Exception('No current user found'); } - $price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product + $priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value()); + $price = (int)$priceResolution['price']; // Get the department price for the product $discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product // Apply the discount percentage to the product price // Apply the discount percentage to the product price + if (products_o::priceResolutionIsCustomMissing($priceResolution)) { + return $price; + } return (int)round($price * (1 - ($discount_percentage / 100))); } @@ -1589,13 +1596,17 @@ class orders_o extends db $product_id = (int)$item['product_id']; if (!isset($department_price_cache[$product_id])) { $product = (new products_o())->select($product_id); - $department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id); + $department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id); } if ($tmp_user === null) { $tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); } $discount = $tmp_user->getCustomPrice($product_id, false); - $post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity; + $unitPrice = (int)$department_price_cache[$product_id]['price']; + if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) { + $unitPrice = (int)round($unitPrice * (1 - ($discount / 100))); + } + $post_discount = $unitPrice * $quantity; $total += $post_discount; } diff --git a/services/nginx/app/objects/products_o.php b/services/nginx/app/objects/products_o.php index eea9c541..fcf21a20 100644 --- a/services/nginx/app/objects/products_o.php +++ b/services/nginx/app/objects/products_o.php @@ -13,6 +13,11 @@ class products_o extends db public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27; public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi'; + public const CUSTOM_PRICING_MISSING_PRICE = 999999; + public const PRICE_SOURCE_DEPARTMENT = 'department'; + public const PRICE_SOURCE_DEFAULT = 'default'; + public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing'; + public const PRICE_SOURCE_KEY = '_department_price_source'; /** * The name of the product @@ -255,24 +260,41 @@ class products_o extends db * @param int $department_id * @return array */ - public function applyDepartmentPricing(array $products, int $department_id): array + public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array { global $db; $department_id = $db->escape_string($department_id); $sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id"; $result = $db->query($sql); $prices = $db->fetch_all($result); + $priceLookup = []; + foreach ($prices as $price) { + $priceLookup[(int)$price['product_id']] = (int)$price['price']; + } + + $customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id); foreach ( $products as $key => $product ) { - foreach ( $prices as $price ) { - if ((int)$product['id'] === (int)$price['product_id']) { - $products[$key]['price'] = $price['price']; - } + $productId = (int)($product['id'] ?? 0); + $source = self::PRICE_SOURCE_DEFAULT; + if (array_key_exists($productId, $priceLookup)) { + $products[$key]['price'] = $priceLookup[$productId]; + $source = self::PRICE_SOURCE_DEPARTMENT; + } elseif ($customPricingOnly) { + $products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE; + $source = self::PRICE_SOURCE_CUSTOM_MISSING; + } + + if ($includePriceSource) { + $products[$key][self::PRICE_SOURCE_KEY] = $source; } } return $products; } - public function getDepartmentPrice(int $department_id): int + /** + * @return array{price:int,source:string} + */ + public function getDepartmentPriceResolution(int $department_id): array { global $db; $department_id = $db->escape_string($department_id); @@ -281,10 +303,27 @@ class products_o extends db $prices = $db->fetch_all($result); // Check if the product has a department price if (count($prices) > 0) { - return $prices[0]['price']; + return [ + 'price' => (int)$prices[0]['price'], + 'source' => self::PRICE_SOURCE_DEPARTMENT, + ]; + } + if ((new departments_o())->isCustomPricingOnly((int)$department_id)) { + return [ + 'price' => self::CUSTOM_PRICING_MISSING_PRICE, + 'source' => self::PRICE_SOURCE_CUSTOM_MISSING, + ]; } // Return the default price - return $this->price->value(); + return [ + 'price' => (int)$this->price->value(), + 'source' => self::PRICE_SOURCE_DEFAULT, + ]; + } + + public function getDepartmentPrice(int $department_id): int + { + return $this->getDepartmentPriceResolution($department_id)['price']; } public function applyCustomerDiscounts(array $products, users_o $customer): array @@ -303,12 +342,26 @@ class products_o extends db // Get the customer's discount percentage $discount_percentage = $customer->getProductDiscountPercentage($product['id']); // Apply the discount to the product price - if ($discount_percentage > 0) { + if ($discount_percentage > 0 && ($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) { $product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100)))); } + unset($product[self::PRICE_SOURCE_KEY]); return $product; } + public static function stripDepartmentPriceSources(array $products): array + { + return array_map(static function (array $product): array { + unset($product[self::PRICE_SOURCE_KEY]); + return $product; + }, $products); + } + + public static function priceResolutionIsCustomMissing(array $resolution): bool + { + return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING; + } + public function getSubscriptionMonthlyPrice(): int { // Subscription price (for 2 washes per month) is 1.2 times the normal price diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 5d84f670..343cb390 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute $product_cache[$product_id] = (new products_o())->select($product_id); } if (!isset($department_price_cache[$department_id][$product_id])) { - $department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id); + $department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id); } if (!array_key_exists($product_id, $discount_cache)) { $discount_cache[$product_id] = $user->getCustomPrice($product_id, false); } - $post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity; + $unit_price = (int)$department_price_cache[$department_id][$product_id]['price']; + if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) { + $unit_price = (int)round($unit_price * (1 - ($discount_cache[$product_id] / 100))); + } + $post_discount = $unit_price * $quantity; $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount); } diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index b96743cd..5cb5692f 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -103,6 +103,7 @@ class departmentsRoute 'economic_department_id', 'visible', 'archived', + 'custom_pricing_only', 'longitude', 'latitude', ]) @@ -123,6 +124,12 @@ class departmentsRoute 'latitude' => (float)$department['latitude'], 'order_priority' => (int)$department['order_priority'], ]; + if ( + $user->hasPermission('superuser_fetch_department') + || $user->hasPermission('edit_department') + ) { + $tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0); + } // If the user has the permission to view the slack webhook, add it to the response if ($user->hasPermission('view_slack_webhook')) { $tmp_department['slack_webhook'] = $department['slack_webhook']; @@ -220,6 +227,9 @@ class departmentsRoute if (self::isParametersSet(['archived'])) { $department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived'))); } + if (self::isParametersSet(['custom_pricing_only'])) { + $department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only'))); + } $department->objectChanged(); // Log the incident (new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department'); diff --git a/services/nginx/app/routes/productsRoute.php b/services/nginx/app/routes/productsRoute.php index f715873e..f19314be 100644 --- a/services/nginx/app/routes/productsRoute.php +++ b/services/nginx/app/routes/productsRoute.php @@ -53,6 +53,19 @@ class productsRoute return null; } + private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void + { + if (!$user instanceof users_o || $departmentId === null) { + return; + } + + if ($this->hasPermission('superuser_fetch_department')) { + return; + } + + $this->requirePermission('department_access_' . $departmentId); + } + /** * Get the category (ID) if the category parameter is provided (In the request 'category') * @return int|null @@ -91,12 +104,14 @@ class productsRoute // Check if the departmentId is set if ($departmentId) { // Apply the departments unique pricing - $products = (new products_o())->applyDepartmentPricing($products, $departmentId); + $products = (new products_o())->applyDepartmentPricing($products, $departmentId, true); } // Check if the customer is set if ($customer !== null) { // Apply the customers unique discounts $products = (new products_o())->applyCustomerDiscounts($products, $customer); + } else { + $products = products_o::stripDepartmentPriceSources($products); } return $products; } @@ -197,6 +212,7 @@ class productsRoute // Define the variables $customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided $departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided + $this->assertCanUseDepartmentPricing($user, $departmentId); $category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category) $productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product) // Check if the "final_price" parameter is set, and true. diff --git a/services/nginx/app/tests/Api/DepartmentsApiTest.php b/services/nginx/app/tests/Api/DepartmentsApiTest.php index 8c68c24a..968e82dc 100644 --- a/services/nginx/app/tests/Api/DepartmentsApiTest.php +++ b/services/nginx/app/tests/Api/DepartmentsApiTest.php @@ -231,6 +231,7 @@ it('updates departments through the real endpoint', function (): void { 'description' => 'Updated description', 'order_priority' => 5, 'archived' => true, + 'custom_pricing_only' => true, ], $session['headers']); $response @@ -246,6 +247,7 @@ it('updates departments through the real endpoint', function (): void { expect($row['description'] ?? null)->toBe('Updated description'); expect((int)($row['order_priority'] ?? 0))->toBe(5); expect((int)($row['archived'] ?? 0))->toBe(1); + expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1); }); it('rejects invalid department update requests', function (): void { diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index 87a780e2..8442c808 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -222,6 +222,85 @@ it('fails price setup gaps without exposing product defaults', function (): void expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']); }); +it('defaults missing custom-only department prices to sentinel without exposing fallback prices', function (): void { + api_test_covers('GET /limited-backoffice/departments', 'happy'); + api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy'); + api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy'); + + $department = api_fixtures()->createDepartment([ + 'name' => 'Limited Custom Pricing Only', + 'custom_pricing_only' => 1, + ]); + $otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Other Pricing']); + $category = api_fixtures()->createCategory(['name' => 'Limited Custom Pricing Category']); + $product = api_fixtures()->createProduct([ + 'name' => 'Custom Missing Product', + 'category' => $category['id'], + 'price' => 87654, + ]); + $otherProduct = api_fixtures()->createProduct([ + 'name' => 'Custom Missing Other Product', + 'category' => $category['id'], + 'price' => 76543, + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']); + limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321); + limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$otherProduct['id'], 5432); + + $session = limited_backoffice_manager_session([(int)$department['id']]); + + $departments = api_client()->get('/limited-backoffice/departments', $session['headers']); + $departments + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect($departments->data()[0]['custom_pricing_only'] ?? null)->toBeTrue(); + + $response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']); + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->body)->not->toContain('87654'); + expect($response->body)->not->toContain('76543'); + expect($response->body)->not->toContain('4321'); + expect($response->body)->not->toContain('5432'); + expect($response->data()['department']['custom_pricing_only'] ?? null)->toBeTrue(); + $products = []; + foreach ($response->data()['categories'] as $departmentCategory) { + foreach ($departmentCategory['products'] as $departmentProduct) { + $products[(int)$departmentProduct['id']] = $departmentProduct; + } + } + expect($products[(int)$product['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); + expect($products[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); + + $updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [ + 'prices' => [ + ['product_id' => (int)$product['id'], 'price' => 2222], + ], + ], $session['headers']); + $updated + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $updatedProducts = []; + foreach ($updated->data()['categories'] as $departmentCategory) { + foreach ($departmentCategory['products'] as $departmentProduct) { + $updatedProducts[(int)$departmentProduct['id']] = $departmentProduct; + } + } + expect($updated->body)->not->toContain('87654'); + expect($updated->body)->not->toContain('76543'); + expect($updated->body)->not->toContain('4321'); + expect($updated->body)->not->toContain('5432'); + expect($updatedProducts[(int)$product['id']]['price'] ?? null)->toBe(2222); + expect($updatedProducts[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); +}); + it('rejects invalid price batches and leaves existing prices unchanged', function (): void { api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation'); diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 577c80f6..8ab143fc 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -4,6 +4,41 @@ declare(strict_types=1); usesApiSuite(); +function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void +{ + $statement = api_test_runtime()->db()->prepare( + 'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`) + VALUES (?, 0, ?, ?)' + ); + $productIdText = (string)$productId; + $statement->bind_param('isi', $userId, $productIdText, $percentage); + $statement->execute(); + $statement->close(); + + api_fixtures()->cleanupDeleteWhere('price_overrides', [ + 'user_id' => $userId, + 'is_category' => 0, + 'product_or_category_id' => $productIdText, + ]); +} + +function custom_pricing_only_department_price(int $departmentId, int $productId, int $price): void +{ + $statement = api_test_runtime()->db()->prepare( + 'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)' + ); + $statement->bind_param('iii', $departmentId, $productId, $price); + $statement->execute(); + $statement->close(); + + api_fixtures()->cleanupDeleteWhere('product_department_prices', [ + 'department_id' => $departmentId, + 'product_id' => $productId, + ]); +} + it('requires notes when adding the extraordinary chemistry product to an order', function (): void { api_test_covers('POST /order/items', 'validation'); @@ -111,3 +146,76 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun expect($response->data()['requires_note'] ?? null)->toBeTrue(); }); + +it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void { + api_test_covers('GET /products', 'happy'); + api_test_covers('POST /order/items', 'happy'); + + $department = api_fixtures()->createDepartment([ + 'name' => 'Custom Pricing Products', + 'custom_pricing_only' => 1, + ]); + $otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']); + $category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']); + $product = api_fixtures()->createProduct([ + 'name' => 'Custom Pricing Missing Product', + 'category' => $category['id'], + 'price' => 12345, + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']); + custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333); + + $customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']); + custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50); + + $session = api_fixtures()->createUserSession([ + 'list_products', + 'add_order_items', + 'department_access_' . (int)$department['id'], + ]); + + $productResponse = api_client()->get( + '/products?final_price=true&id=' . (int)$product['id'] + . '&department_id=' . (int)$department['id'] + . '&customer_id=' . (int)$customer['customer_number'], + $session['headers'] + ); + $productResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($productResponse->body)->not->toContain('12345'); + expect($productResponse->body)->not->toContain('3333'); + expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); + + api_client()->get( + '/products?final_price=true&id=' . (int)$product['id'] + . '&department_id=' . (int)$otherDepartment['id'], + $session['headers'] + ) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]); + + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'CUSTOM-ONLY-ORDER', + ]); + + $orderItem = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + ], $session['headers']); + + $orderItem + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 746e7885..3ef9ef6f 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -132,6 +132,7 @@ final class ApiFixtures 'branding' => (int)($attributes['branding'] ?? 0), 'visible' => (int)($attributes['visible'] ?? 1), 'archived' => (int)($attributes['archived'] ?? 0), + 'custom_pricing_only' => (int)($attributes['custom_pricing_only'] ?? 0), 'latitude' => $attributes['latitude'] ?? 0.0, 'longitude' => $attributes['longitude'] ?? 0.0, 'order_priority' => (int)($attributes['order_priority'] ?? 0), diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 6432a005..00f8338e 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS `departments` ( `branding` INT NULL DEFAULT NULL, `visible` TINYINT(1) NOT NULL DEFAULT 1, `archived` TINYINT(1) NOT NULL DEFAULT 0, + `custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0, `latitude` DECIMAL(10,7) NOT NULL DEFAULT 0, `longitude` DECIMAL(10,7) NOT NULL DEFAULT 0, `order_priority` INT NOT NULL DEFAULT 0, @@ -892,6 +893,13 @@ SQL, 'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)' ); } + + if (!$this->columnExists('departments', 'custom_pricing_only')) { + $this->execute( + 'departments.custom_pricing_only', + 'ALTER TABLE `departments` ADD COLUMN `custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0 AFTER `archived`' + ); + } } private function ensureOrderInvoiceCollectionSchema(): void diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index 56c18f8f..88e6884c 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -618,6 +618,33 @@ it('uses a preloaded e-conomic global discount in expected price breakdowns', fu ]); }); +it('uses the custom-only sentinel without discounts when department price is missing', function (): void { + $row = [ + 'customer_number' => 35131752, + 'user_id' => 411, + 'product_base_price' => 100, + 'department_price' => null, + 'department_custom_pricing_only' => 1, + 'product_discount_percentage' => 50, + 'category_discount_percentage' => 25, + 'apply_category_discount' => 0, + ]; + + $expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]); + $breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]); + + expect($expected)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); + expect($breakdown)->toMatchArray([ + 'product_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE, + 'department_price' => null, + 'effective_base_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE, + 'product_discount_percentage' => 50, + 'category_discount_percentage' => 0, + 'applied_discount_percentage' => 0, + 'expected_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE, + ]); +}); + it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void { $row = [ 'customer_number' => 35131752, From 84dec4c0a24441aa1bd553bd7752e08cc97713ea Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 10:34:57 +0200 Subject: [PATCH 02/14] Stabilize custom pricing API fixture --- services/nginx/app/tests/Api/OrderItemsApiTest.php | 1 + services/nginx/app/tests/Support/Api/ApiFixtures.php | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 8ab143fc..6693114d 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -167,6 +167,7 @@ it('uses the sentinel for missing custom-only department prices without discount custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333); $customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']); + api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0); custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50); $session = api_fixtures()->createUserSession([ diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 3ef9ef6f..1de519c9 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -1710,6 +1710,17 @@ final class ApiFixtures $this->cleanup->add(fn() => $this->deleteWhere($table, $conditions)); } + public function cacheEconomicCustomerDiscountPercentage(int $userId, int $discountPercentage): void + { + if ($this->redis === null) { + throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.'); + } + + $key = 'users_' . $userId . '_economic_customer_discount_percentage'; + $this->redis->set($key, (string)$discountPercentage); + $this->cleanup->add(fn() => $this->deleteRedisKey($key)); + } + private function purgeCustomerTraceData(int $userId, int $customerNumber): void { $invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [ From 215c8d0fbb009982a65ce5eaf985246998c2b16b Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 10:38:51 +0200 Subject: [PATCH 03/14] Add limited backoffice role permission details --- .../classes/limited_backoffice_service.php | 154 +++++++++++++++++- .../tests/Api/LimitedBackofficeApiTest.php | 47 ++++++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index d8ecd4c4..8e786258 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -112,6 +112,124 @@ class limited_backoffice_service ], ]; + /** + * @var array + */ + private const ROLE_PERMISSION_CAPABILITIES = [ + 'user' => [ + 'group' => 'account', + 'capability' => 'sign_in', + ], + 'permissions_list_own' => [ + 'group' => 'account', + 'capability' => 'view_own_permissions', + ], + 'list_orders' => [ + 'group' => 'orders', + 'capability' => 'view_orders', + ], + 'add_order' => [ + 'group' => 'orders', + 'capability' => 'create_orders', + ], + 'edit_order' => [ + 'group' => 'orders', + 'capability' => 'edit_orders', + ], + 'delete_order' => [ + 'group' => 'orders', + 'capability' => 'delete_orders', + ], + 'list_order_items' => [ + 'group' => 'orders', + 'capability' => 'view_order_items', + ], + 'add_order_items' => [ + 'group' => 'orders', + 'capability' => 'create_order_items', + ], + 'edit_order_items' => [ + 'group' => 'orders', + 'capability' => 'update_order_lines', + ], + 'delete_order_items' => [ + 'group' => 'orders', + 'capability' => 'remove_order_lines', + ], + 'charge_order' => [ + 'group' => 'orders', + 'capability' => 'charge_orders', + ], + 'list_bookings' => [ + 'group' => 'bookings', + 'capability' => 'view_department_bookings', + ], + 'list_own_bookings' => [ + 'group' => 'bookings', + 'capability' => 'view_own_bookings', + ], + 'edit_bookings' => [ + 'group' => 'bookings', + 'capability' => 'update_bookings', + ], + 'add_booking' => [ + 'group' => 'bookings', + 'capability' => 'create_bookings', + ], + 'complete_bookings' => [ + 'group' => 'bookings', + 'capability' => 'mark_bookings_complete', + ], + 'resend_booking_confirmations' => [ + 'group' => 'bookings', + 'capability' => 'send_booking_confirmations', + ], + 'department_timebookings_entries_get' => [ + 'group' => 'time_bookings', + 'capability' => 'view_time_booking_entries', + ], + 'department_timebookings_entries_post' => [ + 'group' => 'time_bookings', + 'capability' => 'create_time_booking_entries', + ], + 'department_timebookings_entries_put' => [ + 'group' => 'time_bookings', + 'capability' => 'edit_time_booking_entries', + ], + 'statistics_orders_new' => [ + 'group' => 'reports', + 'capability' => 'view_order_statistics', + ], + 'statistics_bookings_new' => [ + 'group' => 'reports', + 'capability' => 'view_booking_statistics', + ], + self::PERMISSION_ACCESS => [ + 'group' => 'limited_backoffice', + 'capability' => 'open_limited_backoffice', + ], + self::PERMISSION_MANAGE_PRICES => [ + 'group' => 'limited_backoffice', + 'capability' => 'manage_department_prices', + ], + self::PERMISSION_MANAGE_EMPLOYEES => [ + 'group' => 'limited_backoffice', + 'capability' => 'manage_employee_access', + ], + ]; + + /** + * @var array + */ + private const ROLE_PERMISSION_GROUP_ORDER = [ + 'account', + 'orders', + 'bookings', + 'time_bookings', + 'reports', + 'limited_backoffice', + ]; + /** * @var array */ @@ -123,7 +241,7 @@ class limited_backoffice_service } /** - * @return array + * @return array}>}> */ public function rolePresets(): array { @@ -133,11 +251,45 @@ class limited_backoffice_service 'key' => $key, 'label' => $preset['label'], 'description' => $preset['description'], + 'permission_groups' => $this->rolePermissionGroups($preset['permissions']), ]; } return $roles; } + /** + * @param array $permissions + * @return array}> + */ + private function rolePermissionGroups(array $permissions): array + { + $groups = []; + foreach ($permissions as $permission) { + $capability = self::ROLE_PERMISSION_CAPABILITIES[$permission] ?? null; + if ($capability === null) { + throw new \RuntimeException('Missing limited backoffice role capability for permission: ' . $permission); + } + + $group = $capability['group']; + $groups[$group] ??= []; + $groups[$group][] = $capability['capability']; + } + + $payload = []; + foreach (self::ROLE_PERMISSION_GROUP_ORDER as $group) { + if (!isset($groups[$group])) { + continue; + } + + $payload[] = [ + 'key' => $group, + 'capabilities' => array_values(array_unique($groups[$group])), + ]; + } + + return $payload; + } + /** * @return array */ diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index 87a780e2..a685d338 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -285,7 +285,54 @@ it('creates updates lists and deactivates scoped employees without exposing raw ->assertSuccess(); expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']); + $rolesByKey = array_column($roles->data(), null, 'key'); + expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([ + [ + 'key' => 'account', + 'capabilities' => ['sign_in', 'view_own_permissions'], + ], + ]); + $departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key'); + expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([ + 'open_limited_backoffice', + 'manage_department_prices', + 'manage_employee_access', + ]); expect($roles->body)->not->toContain('department_access_'); + $rolePayload = $roles->data(); + $rolePayloadStrings = []; + array_walk_recursive($rolePayload, static function ($value) use (&$rolePayloadStrings): void { + if (is_string($value)) { + $rolePayloadStrings[] = $value; + } + }); + foreach ([ + 'list_orders', + 'add_order', + 'edit_order', + 'delete_order', + 'list_order_items', + 'add_order_items', + 'edit_order_items', + 'delete_order_items', + 'charge_order', + 'list_bookings', + 'list_own_bookings', + 'edit_bookings', + 'add_booking', + 'complete_bookings', + 'resend_booking_confirmations', + 'department_timebookings_entries_get', + 'department_timebookings_entries_post', + 'department_timebookings_entries_put', + 'statistics_orders_new', + 'statistics_bookings_new', + 'limited_backoffice_access', + 'limited_backoffice_prices_manage', + 'limited_backoffice_employees_manage', + ] as $rawPermission) { + expect($rolePayloadStrings)->not->toContain($rawPermission); + } $created = api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Limited Cashier', From 38814545c426c5a516bd65d32019989156aff5ec Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 11:31:22 +0200 Subject: [PATCH 04/14] Optimize collected e-conomic invoice transfers --- .../classes/economic_transfer_executor.php | 8 +- .../economic_invoices_draft_endpoint.php | 38 +++++++- .../helpers/economic_invoice_draft.php | 50 +++++++++- .../objects/collected_order_invoices_o.php | 21 ++++- ...InvoiceEconomicBatchTransferWiringTest.php | 50 ++++++++++ .../EconomicInvoiceDraftLineBatchingTest.php | 92 +++++++++++++++++++ 6 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftLineBatchingTest.php diff --git a/services/nginx/app/classes/economic_transfer_executor.php b/services/nginx/app/classes/economic_transfer_executor.php index 3b1d75c3..c00d3e37 100644 --- a/services/nginx/app/classes/economic_transfer_executor.php +++ b/services/nginx/app/classes/economic_transfer_executor.php @@ -240,7 +240,13 @@ class economic_transfer_executor 'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id ); - return $collected_order_invoices->asArray(); + $result = $collected_order_invoices->asArray(); + $transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics(); + if ($transfer_metrics !== null) { + $result['economic_transfer_metrics'] = $transfer_metrics; + } + + return $result; } /** diff --git a/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php index a41a5f83..10ab5f9a 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php @@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint * @throws Exception If the request fails */ public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void + { + $this->add_orders($invoiceDraftId, [$order], $currency); + } + + /** + * Add many orders to a draft invoice and flush their lines in batches. + * + * @param orders_o[] $orders + * @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array} + * @throws Exception If the request fails + */ + public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array { $draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true); - // Check if the order includes any items that should be included in the invoice - if ($order->getIncludeInInvoiceCount() > 0) { + $orders_with_invoice_lines = 0; + + foreach ( $orders as $order ) { + if (!$order instanceof orders_o) { + throw new Exception('Order payload must contain orders_o instances'); + } + // Check if the order includes any items that should be included in the invoice + if ($order->getIncludeInInvoiceCount() <= 0) { + continue; + } + + $orders_with_invoice_lines++; // Add the transaction header (Timestamp, department, etc.) $draftInvoice->addNewTransactionHeader($order); // Add the order lines $draftInvoice->addOrderItemLines($order); // Add an empty line, so the invoice is not empty $draftInvoice->addTextLine(''); - // Save the draft invoice lines - $draftInvoice->addLines(); } + + $metrics = $draftInvoice->flushLinesInBatches($line_batch_size); + + return [ + 'order_count' => count($orders), + 'orders_with_invoice_lines' => $orders_with_invoice_lines, + ...$metrics, + ]; } /** @@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint $draft_invoice->addLines(); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index 76c99160..09596823 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -10,6 +10,8 @@ use objects\orders_o; class economic_invoice_draft { + public const DEFAULT_LINE_BATCH_SIZE = 500; + /** * The Economic draftInvoiceNumber * @var int $draft_invoice_number @@ -110,13 +112,55 @@ class economic_invoice_draft } /** - * Add the lines to the draft invoice - * @return void + * Add the lines to the draft invoice. */ public function addLines(): void + { + $this->flushLinesInBatches(); + } + + /** + * Add queued draft lines using chunked requests. + * + * @return array{line_count:int,batch_count:int,batch_sizes:array} + */ + public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array + { + $lines = array_values($this->draft_lines); + $line_count = count($lines); + if ($line_count === 0) { + return [ + 'line_count' => 0, + 'batch_count' => 0, + 'batch_sizes' => [], + ]; + } + + $batch_size = max(1, $batch_size); + $batch_sizes = []; + foreach (array_chunk($lines, $batch_size) as $batch) { + $this->sendDraftLines($batch); + $batch_sizes[] = count($batch); + } + + $this->draft_lines = []; + + return [ + 'line_count' => $line_count, + 'batch_count' => count($batch_sizes), + 'batch_sizes' => $batch_sizes, + ]; + } + + public function pendingLineCount(): int + { + return count($this->draft_lines); + } + + protected function sendDraftLines(array $draft_lines): object { $economic = new economic(); - $economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines); + return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines); } /** diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index c9020ad4..65734b7c 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -37,6 +37,7 @@ class collected_order_invoices_o extends db public object_property $updated_at; public object_property $closed_at; public int $economic_wash_subscription_user_id = 1857; + private ?array $last_economic_transfer_metrics = null; /** * The processor types * @@ -712,6 +713,7 @@ class collected_order_invoices_o extends db */ public function addInvoicesToDraft(bool $skip_check = false): self { + $this->last_economic_transfer_metrics = null; // Require the invoice collection to be selected self::requireSelected(); // Require the invoice collection to be open @@ -736,10 +738,20 @@ class collected_order_invoices_o extends db usort($orders, function ($a, $b) { return strtotime($a['created_at']) - strtotime($b['created_at']); }); - // Add the invoices to the invoice draft + // Add the invoice lines to the draft in one accumulated batch path. + $order_objects = []; foreach ( $orders as $order ) { - self::addInvoiceToDraft($order['id'], true, $draft_id, $currency); + $order_object = new orders_o(); + $order_object->select((int)$order['id']); + $order_object->requireSelected(); + $order_objects[] = $order_object; } + $metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency); + $this->last_economic_transfer_metrics = [ + 'draft_invoice_id' => $draft_id, + 'currency' => (string)$currency, + ...$metrics, + ]; // If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency); // Object changed @@ -748,6 +760,11 @@ class collected_order_invoices_o extends db return $this; } + public function getLastEconomicTransferMetrics(): ?array + { + return $this->last_economic_transfer_metrics; + } + /** * Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute * @param int $draft_id The invoice draft id diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php new file mode 100644 index 00000000..520d821b --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php @@ -0,0 +1,50 @@ +not->toBeFalse(); + $content = (string)$content; + + $start = strpos($content, 'public function addInvoicesToDraft'); + $end = strpos($content, 'public function getLastEconomicTransferMetrics'); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $methodBlock = substr($content, (int)$start, (int)$end - (int)$start); + expect($methodBlock)->toContain('$order_objects = [];') + ->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);') + ->and($methodBlock)->toContain('...$metrics') + ->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);'); +}); + +it('keeps single-order draft uploads as a wrapper around the batch endpoint', function (): void { + $content = file_get_contents(dirname(__DIR__, 3) . '/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php'); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + $singleStart = strpos($content, 'public function add_order'); + $singleEnd = strpos($content, 'public function add_orders'); + expect($singleStart)->not->toBeFalse(); + expect($singleEnd)->not->toBeFalse(); + expect($singleEnd)->toBeGreaterThan($singleStart); + + $singleBlock = substr($content, (int)$singleStart, (int)$singleEnd - (int)$singleStart); + expect($singleBlock)->toContain('$this->add_orders($invoiceDraftId, [$order], $currency);'); + + $batchBlock = substr($content, (int)$singleEnd); + expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);') + ->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines"); +}); + +it('includes collected invoice batch transfer metrics in queue results when available', function (): void { + $content = file_get_contents(dirname(__DIR__, 3) . '/classes/economic_transfer_executor.php'); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();') + ->and($content)->toContain("\$result['economic_transfer_metrics'] = \$transfer_metrics;"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftLineBatchingTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftLineBatchingTest.php new file mode 100644 index 00000000..dccc190f --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftLineBatchingTest.php @@ -0,0 +1,92 @@ +sentBatches[] = $draft_lines; + return (object)['lines' => $draft_lines]; + } + } +} + +if (!class_exists('EconomicInvoiceDraftFailingBatchingProbe')) { + class EconomicInvoiceDraftFailingBatchingProbe extends EconomicInvoiceDraftBatchingProbe + { + public int $failOnBatch = 1; + + protected function sendDraftLines(array $draft_lines): object + { + if (count($this->sentBatches) + 1 === $this->failOnBatch) { + throw new RuntimeException('Simulated e-conomic line batch failure'); + } + + return parent::sendDraftLines($draft_lines); + } + } +} + +it('does not call e-conomic when flushing an empty draft line buffer', function (): void { + $draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true); + + $metrics = $draft->flushLinesInBatches(); + + expect($metrics)->toBe([ + 'line_count' => 0, + 'batch_count' => 0, + 'batch_sizes' => [], + ])->and($draft->sentBatches)->toBe([]); +}); + +it('flushes a small draft line buffer in one request and clears pending lines', function (): void { + $draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true); + $draft->addTextLine('line-0'); + $draft->addTextLine('line-1'); + $draft->addTextLine('line-2'); + + $metrics = $draft->flushLinesInBatches(500); + + expect($metrics)->toBe([ + 'line_count' => 3, + 'batch_count' => 1, + 'batch_sizes' => [3], + ])->and($draft->sentBatches)->toHaveCount(1) + ->and($draft->sentBatches[0][0]['description'])->toBe('line-0') + ->and($draft->sentBatches[0][2]['description'])->toBe('line-2') + ->and($draft->pendingLineCount())->toBe(0); +}); + +it('chunks large draft line buffers while preserving line order', function (): void { + $draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true); + for ($i = 0; $i < 1201; $i++) { + $draft->addTextLine('line-' . $i); + } + + $metrics = $draft->flushLinesInBatches(500); + + expect($metrics)->toBe([ + 'line_count' => 1201, + 'batch_count' => 3, + 'batch_sizes' => [500, 500, 201], + ])->and($draft->sentBatches)->toHaveCount(3) + ->and($draft->sentBatches[0][0]['description'])->toBe('line-0') + ->and($draft->sentBatches[1][0]['description'])->toBe('line-500') + ->and($draft->sentBatches[2][200]['description'])->toBe('line-1200') + ->and($draft->pendingLineCount())->toBe(0); +}); + +it('bubbles line batch failures and keeps pending lines available', function (): void { + $draft = new EconomicInvoiceDraftFailingBatchingProbe(123, 'DKK', true); + $draft->addTextLine('line-0'); + + expect(fn () => $draft->flushLinesInBatches(500)) + ->toThrow(RuntimeException::class, 'Simulated e-conomic line batch failure'); + + expect($draft->sentBatches)->toBe([]) + ->and($draft->pendingLineCount())->toBe(1); +}); From 430c90cbca9771292441557ee02f99b4c4cad570 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 12:19:58 +0200 Subject: [PATCH 05/14] Enforce only tankcleaning order products --- .../classes/customer_order_product_policy.php | 98 +++++++++++++++++++ .../classes/invoice_period_flag_service.php | 3 +- services/nginx/app/objects/order_items_o.php | 5 +- .../nginx/app/tests/Api/OrderItemsApiTest.php | 84 +++++++++++++++- .../Orders/CustomerOrderProductPolicyTest.php | 40 ++++++++ 5 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 services/nginx/app/classes/customer_order_product_policy.php create mode 100644 services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php diff --git a/services/nginx/app/classes/customer_order_product_policy.php b/services/nginx/app/classes/customer_order_product_policy.php new file mode 100644 index 00000000..cca75373 --- /dev/null +++ b/services/nginx/app/classes/customer_order_product_policy.php @@ -0,0 +1,98 @@ +query($sql); + if (!$result || $result->num_rows < 1) { + return null; + } + + $row = $result->fetch_assoc(); + return is_array($row) ? $row : null; + } + + private static function rowMatchesProductTerms(array $row, array $terms): bool + { + $haystack = strtolower(trim( + (string)($row['product_name'] ?? $row['name'] ?? '') . ' ' . + (string)($row['category_name'] ?? '') + )); + + foreach ($terms as $term) { + if ($term !== '' && str_contains($haystack, strtolower($term))) { + return true; + } + } + + return false; + } +} diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index bb8e0a56..09778baf 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -2036,8 +2036,7 @@ class invoice_period_flag_service private function rowIsTankCleaningProduct(array $row): bool { - return (int)($row['product_category'] ?? 0) === 5 - || $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']); + return customer_order_product_policy::isTankCleaningProductRow($row); } private function isIncludedOrderItem(array $row): bool diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index 38a43603..248a4603 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -3,6 +3,7 @@ namespace objects; use classes\db; +use classes\customer_order_product_policy; use classes\object_property; use Exception; use traits\db_object_t; @@ -93,6 +94,7 @@ class order_items_o extends db { global $db, $response; try { + customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id); // Avoid SQL injection $reference = $db->escape_string($reference); $notes = $db->escape_string($notes); @@ -167,6 +169,7 @@ class order_items_o extends db try { // Get the order $order = (new orders_o())->getOrderById($order_id); + customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id); // Get the product price $price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value()); @@ -354,4 +357,4 @@ class order_items_o extends db { return (new products_o())->select((int)$this->product_id->value()); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 577c80f6..5e21cacc 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -15,7 +15,6 @@ it('requires notes when adding the extraordinary chemistry product to an order', 'reference' => 'NOTE-REQUIRED', ]); $product = api_fixtures()->createProduct([ - 'id' => 902701, 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, 'price' => 299, 'requires_note' => 0, @@ -49,6 +48,85 @@ it('requires notes when adding the extraordinary chemistry product to an order', expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side'); }); +it('only allows tankcleaning products for only tankcleaning customers', function (): void { + api_test_covers('POST /order/items', 'customer_rules'); + + $customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']); + api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning'); + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'ONLY-TANK', + ]); + $washProduct = api_fixtures()->createProduct([ + 'name' => 'Forvogn', + 'price' => 649, + 'category' => 4, + ]); + $tankCleaningProduct = api_fixtures()->createProduct([ + 'name' => 'Saebe/kemi, 1-4 spulehoveder', + 'price' => 299, + 'category' => 5, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + api_client() + ->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $washProduct['id'], + 'quantity' => 1, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_order_product_policy::ONLY_TANKCLEANING_MESSAGE); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $tankCleaningProduct['id'], + 'quantity' => 1, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']); +}); + +it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void { + api_test_covers('POST /order/items', 'customer_rules'); + + $customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']); + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'REGULAR-WASH', + ]); + $washProduct = api_fixtures()->createProduct([ + 'name' => 'Forvogn', + 'price' => 649, + 'category' => 4, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $washProduct['id'], + 'quantity' => 1, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']); +}); + it('does not allow clearing notes for order items whose product requires notes', function (): void { api_test_covers('PUT /order/items', 'validation'); @@ -62,7 +140,6 @@ it('does not allow clearing notes for order items whose product requires notes', 'reference' => 'NOTE-EDIT', ]); $product = api_fixtures()->createProduct([ - 'id' => 902702, 'name' => 'API Note Required Product', 'price' => 199, 'requires_note' => 1, @@ -75,7 +152,7 @@ it('does not allow clearing notes for order items whose product requires notes', 'quantity' => 1, 'notes' => 'Initial note', ]); - $session = api_fixtures()->createUserSession(['edit_order_items']); + $session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']); api_client() ->put('/order/items', [ @@ -95,7 +172,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun api_test_covers('GET /products', 'happy'); $product = api_fixtures()->createProduct([ - 'id' => 902703, 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, 'price' => 299, 'requires_note' => 0, diff --git a/services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php b/services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php new file mode 100644 index 00000000..8da99e2b --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/CustomerOrderProductPolicyTest.php @@ -0,0 +1,40 @@ + 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(); +}); From 6f3d7e0f7d31d44414a3aecc80ca7510c1982fe5 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 13:14:06 +0200 Subject: [PATCH 06/14] Add limited backoffice employee contact fields (#294) Co-authored-by: Jeppe Bundgaard --- .../classes/limited_backoffice_service.php | 123 +++++++++++++++- .../tests/Api/LimitedBackofficeApiTest.php | 138 ++++++++++++++++++ 2 files changed, 255 insertions(+), 6 deletions(-) diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index a94c0551..c3798e44 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -230,6 +230,16 @@ class limited_backoffice_service 'limited_backoffice', ]; + /** + * @var array + */ + private const PHONE_COUNTRY_CODES = [ + 45 => true, + 46 => true, + 47 => true, + 358 => true, + ]; + /** * @var array */ @@ -534,7 +544,8 @@ class limited_backoffice_service $roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null); $displayName = $this->normalizeRequiredString($payload['display_name'] ?? null, 'Display name is required.'); $password = $this->normalizePassword($payload['password'] ?? null, true); - $email = $this->normalizeOptionalString($payload['email'] ?? null); + $email = $this->normalizeEmail($payload['email'] ?? null, true); + $phone = $this->normalizeOptionalPhonePair($payload); $mysqli = $this->mysqli(); $mysqli->begin_transaction(); @@ -545,13 +556,23 @@ class limited_backoffice_service $passwordHash = password_hash($password, PASSWORD_DEFAULT); $statement = $mysqli->prepare( - 'INSERT INTO `users` (`customer_number`, `display_name`, `email`, `password`, `group_id`) - VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO `users` + (`customer_number`, `display_name`, `email`, `password`, `group_id`, `phone_country_code`, `phone`) + VALUES (?, ?, ?, ?, ?, ?, ?)' ); if ($statement === false) { throw new \RuntimeException('Unable to prepare employee insert.'); } - $statement->bind_param('isssi', $customerNumber, $displayName, $email, $passwordHash, $groupId); + $statement->bind_param( + 'isssiii', + $customerNumber, + $displayName, + $email, + $passwordHash, + $groupId, + $phone['phone_country_code'], + $phone['phone'] + ); $statement->execute(); $employeeId = (int)$mysqli->insert_id; $statement->close(); @@ -629,11 +650,12 @@ class limited_backoffice_service ? $this->normalizeRequiredString($payload['display_name'], 'Display name is required.') : null; $email = array_key_exists('email', $payload) - ? $this->normalizeOptionalString($payload['email']) + ? $this->normalizeEmail($payload['email'], true) : null; $password = array_key_exists('password', $payload) ? $this->normalizePassword($payload['password'], false) : null; + $phone = $this->normalizeOptionalPhonePair($payload, false); $active = array_key_exists('active', $payload) ? (bool)$payload['active'] : $this->isEmployeeRowActive($employee); @@ -663,6 +685,10 @@ class limited_backoffice_service if ($password !== null) { $userUpdates['password'] = password_hash($password, PASSWORD_DEFAULT); } + if ($phone !== null) { + $userUpdates['phone_country_code'] = $phone['phone_country_code']; + $userUpdates['phone'] = $phone['phone']; + } $usersHaveDeletedAt = $this->tableHasColumn('users', 'deleted_at'); if ($active) { $userUpdates['group_id'] = $managedGroupId; @@ -1061,6 +1087,89 @@ class limited_backoffice_service return $value === '' ? null : $value; } + private function normalizeEmail(mixed $value, bool $required): ?string + { + $email = $this->normalizeOptionalString($value); + if ($email === null) { + if ($required) { + throw new limited_backoffice_exception('Email is required.', 400); + } + return null; + } + + if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + throw new limited_backoffice_exception('Email must be a valid email address.', 400); + } + + return $email; + } + + /** + * @param array $payload + * @return array{phone_country_code:int|null,phone:int|null}|null + */ + private function normalizeOptionalPhonePair(array $payload, bool $defaultWhenMissing = true): ?array + { + $hasCountryCode = array_key_exists('phone_country_code', $payload); + $hasPhone = array_key_exists('phone', $payload); + if (!$hasCountryCode && !$hasPhone) { + return $defaultWhenMissing + ? ['phone_country_code' => null, 'phone' => null] + : null; + } + + if (!$hasCountryCode || !$hasPhone) { + throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400); + } + + $countryCode = $this->normalizeOptionalDigits($payload['phone_country_code']); + $phone = $this->normalizeOptionalDigits($payload['phone']); + if ($countryCode === null && $phone === null) { + return ['phone_country_code' => null, 'phone' => null]; + } + + if ($countryCode === null || $phone === null) { + throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400); + } + + if (!isset(self::PHONE_COUNTRY_CODES[$countryCode])) { + throw new limited_backoffice_exception('Phone country code is not supported.', 400); + } + + $phoneText = (string)$phone; + if (!preg_match('/^\d{4,15}$/', $phoneText)) { + throw new limited_backoffice_exception('Phone number must be 4-15 digits.', 400); + } + + return [ + 'phone_country_code' => $countryCode, + 'phone' => $phone, + ]; + } + + private function normalizeOptionalDigits(mixed $value): ?int + { + if ($value === null) { + return null; + } + + if (is_int($value)) { + return $value > 0 ? $value : null; + } + + if (is_string($value)) { + $value = trim($value); + if ($value === '') { + return null; + } + if (ctype_digit($value)) { + return (int)$value; + } + } + + throw new limited_backoffice_exception('Phone values must contain digits only.', 400); + } + private function normalizePassword(mixed $value, bool $required): ?string { if ($value === null || $value === '') { @@ -1236,6 +1345,8 @@ class limited_backoffice_service 'customer_number' => (int)$row['customer_number'], 'display_name' => (string)($row['display_name'] ?? ''), 'email' => $row['email'] === null ? null : (string)$row['email'], + 'phone_country_code' => $row['phone_country_code'] === null ? null : (int)$row['phone_country_code'], + 'phone' => $row['phone'] === null ? null : (int)$row['phone'], 'active' => $active, 'role' => $this->rolePayload((string)$row['role_key']), 'departments' => $this->departmentSummaries($departmentIds), @@ -1356,7 +1467,7 @@ class limited_backoffice_service $types = ''; $values = []; foreach ($fields as $field => $value) { - if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at'], true)) { + if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at', 'phone_country_code', 'phone'], true)) { continue; } if ($value === null) { diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index 55ddab06..d7b411b4 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -400,6 +400,8 @@ it('creates updates lists and deactivates scoped employees without exposing raw $created = api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Limited Cashier', 'email' => 'limited-cashier@example.test', + 'phone_country_code' => 45, + 'phone' => 12345678, 'password' => 'Secret123!', 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], @@ -413,6 +415,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw $employeeId = (int)($created->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); + expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test'); + expect($created->data()['phone_country_code'] ?? null)->toBe(45); + expect($created->data()['phone'] ?? null)->toBe(12345678); expect($created->body)->not->toContain('department_access_'); expect($created->body)->not->toContain('permissions'); @@ -431,6 +436,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw $updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [ 'display_name' => 'Limited Lead', + 'email' => 'limited-lead@example.test', + 'phone_country_code' => 358, + 'phone' => 87654321, 'role_key' => 'operations_lead', 'department_ids' => [(int)$department['id']], ], $session['headers']); @@ -440,11 +448,25 @@ it('creates updates lists and deactivates scoped employees without exposing raw ->assertSuccess(); expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead'); + expect($updated->data()['email'] ?? null)->toBe('limited-lead@example.test'); + expect($updated->data()['phone_country_code'] ?? null)->toBe(358); + expect($updated->data()['phone'] ?? null)->toBe(87654321); expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead'); $list = api_client()->get('/limited-backoffice/employees', $session['headers']); $ids = array_column($list->data(), 'id'); expect($ids)->toContain($employeeId); + $listedEmployee = null; + foreach ($list->data() as $employee) { + if ((int)($employee['id'] ?? 0) === $employeeId) { + $listedEmployee = $employee; + break; + } + } + expect($listedEmployee)->not->toBeNull(); + expect($listedEmployee['email'] ?? null)->toBe('limited-lead@example.test'); + expect($listedEmployee['phone_country_code'] ?? null)->toBe(358); + expect($listedEmployee['phone'] ?? null)->toBe(87654321); expect($list->body)->not->toContain('department_access_'); $deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']); @@ -466,6 +488,34 @@ it('creates updates lists and deactivates scoped employees without exposing raw }); }); +it('accepts employees without optional phone details', function (): void { + api_test_covers('POST /limited-backoffice/employees', 'happy'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Employee No Phone']); + $session = limited_backoffice_manager_session([(int)$department['id']]); + + $created = api_client()->post('/limited-backoffice/employees', [ + 'display_name' => 'Limited No Phone', + 'email' => 'limited-no-phone@example.test', + 'password' => 'Secret123!', + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $session['headers']); + + $created + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $employeeId = (int)($created->data()['id'] ?? 0); + expect($employeeId)->toBeGreaterThan(0); + limited_backoffice_cleanup_created_employee($employeeId); + expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue(); + expect(array_key_exists('phone', $created->data()))->toBeTrue(); + expect($created->data()['phone_country_code'])->toBeNull(); + expect($created->data()['phone'])->toBeNull(); +}); + it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void { api_test_covers('POST /limited-backoffice/employees', 'validation'); api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation'); @@ -476,6 +526,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Outside Employee', + 'email' => 'outside@example.test', 'password' => 'Secret123!', 'role_key' => 'cashier', 'department_ids' => [(int)$otherDepartment['id']], @@ -487,6 +538,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Raw Employee', + 'email' => 'raw@example.test', 'password' => 'Secret123!', 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], @@ -499,6 +551,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Unknown Role Employee', + 'email' => 'unknown-role@example.test', 'password' => 'Secret123!', 'role_key' => 'superuser', 'department_ids' => [(int)$department['id']], @@ -551,3 +604,88 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar ->assertSuccess(false) ->assertMessage('Cannot manage shared groups.'); }); + +it('rejects invalid limited backoffice employee contact details', function (): void { + api_test_covers('POST /limited-backoffice/employees', 'validation'); + api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Employee Contact Validation']); + $session = limited_backoffice_manager_session([(int)$department['id']]); + $basePayload = [ + 'display_name' => 'Contact Employee', + 'email' => 'contact@example.test', + 'password' => 'Secret123!', + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ]; + + api_client()->post('/limited-backoffice/employees', array_diff_key($basePayload, ['email' => true]), $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Email is required.'); + + api_client()->post('/limited-backoffice/employees', [ + ...$basePayload, + 'email' => 'not-an-email', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Email must be a valid email address.'); + + api_client()->post('/limited-backoffice/employees', [ + ...$basePayload, + 'phone_country_code' => 45, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Phone country code and phone number must be provided together.'); + + api_client()->post('/limited-backoffice/employees', [ + ...$basePayload, + 'phone_country_code' => 1, + 'phone' => 12345678, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Phone country code is not supported.'); + + api_client()->post('/limited-backoffice/employees', [ + ...$basePayload, + 'phone_country_code' => 45, + 'phone' => '12ab', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Phone values must contain digits only.'); + + $created = api_client()->post('/limited-backoffice/employees', $basePayload, $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $employeeId = (int)($created->data()['id'] ?? 0); + expect($employeeId)->toBeGreaterThan(0); + limited_backoffice_cleanup_created_employee($employeeId); + + api_client()->put('/limited-backoffice/employees/' . $employeeId, [ + 'email' => '', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Email is required.'); + + api_client()->put('/limited-backoffice/employees/' . $employeeId, [ + 'phone_country_code' => 45, + 'phone' => '123', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Phone number must be 4-15 digits.'); +}); From 62f2c80dda4d2080330dd10885c37c162b65787f Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 13:36:39 +0200 Subject: [PATCH 07/14] Scope monthly invoice split endpoint --- .../nginx/app/routes/orderInvoicesRoute.php | 49 ++++-- .../CollectedInvoiceMonthlySplitApiTest.php | 151 ++++++++++++++++++ 2 files changed, 188 insertions(+), 12 deletions(-) diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index a4a6ae92..9a9681e3 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -612,21 +612,46 @@ class orderInvoicesRoute $preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month' ); - $date_from = $db->escape_string($date_range['dateFrom']); - $date_to = $db->escape_string($date_range['dateTo']); - $sql = "SELECT DISTINCT invoice_collection_id - FROM orders - WHERE created_at BETWEEN '$date_from' AND '$date_to' - AND invoice_collection_id IS NOT NULL - AND invoice_collection_id > 0 - AND deleted_at IS NULL"; - $query_result = $db->query($sql); $invoice_collection_ids = []; - while ($row = $query_result->fetch_assoc()) { - $invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0); - if ($invoice_collection_id > 0) { + if (self::isParametersSet(['invoice_collection_ids'])) { + $invoice_collection_ids_raw = self::getParameter('invoice_collection_ids'); + if (!is_array($invoice_collection_ids_raw)) { + $response->error('invoice_collection_ids must be an array', 400); + } + + foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) { + if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) { + $response->error('invoice_collection_ids must contain only positive integer ids', 400); + } + + $invoice_collection_id = (int)$invoice_collection_id_raw; + if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) { + $response->error('invoice_collection_ids must contain only positive integer ids', 400); + } + $invoice_collection_ids[] = $invoice_collection_id; } + + $invoice_collection_ids = array_values(array_unique($invoice_collection_ids)); + if (empty($invoice_collection_ids)) { + $response->error('invoice_collection_ids must contain at least one id', 400); + } + } else { + $date_from = $db->escape_string($date_range['dateFrom']); + $date_to = $db->escape_string($date_range['dateTo']); + $sql = "SELECT DISTINCT invoice_collection_id + FROM orders + WHERE created_at BETWEEN '$date_from' AND '$date_to' + AND invoice_collection_id IS NOT NULL + AND invoice_collection_id > 0 + AND deleted_at IS NULL"; + $query_result = $db->query($sql); + while ($row = $query_result->fetch_assoc()) { + $invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0); + if ($invoice_collection_id > 0) { + $invoice_collection_ids[] = $invoice_collection_id; + } + } } $items = []; diff --git a/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php index 336129dc..621a1ba6 100644 --- a/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php +++ b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php @@ -85,6 +85,65 @@ it('previews monthly split changes without moving orders or creating collections ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); }); +it('previews only explicit monthly split invoice collection ids', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'preview-scope'); + + $customer = api_fixtures()->createUser(['display_name' => 'Scoped Preview Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $targetCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $ignoredCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $targetMarchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $targetCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $targetAprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $targetCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $ignoredMarchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $ignoredCollection['id'], + 'created_at' => '2096-03-16 10:00:00', + ]); + $ignoredAprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $ignoredCollection['id'], + 'created_at' => '2096-04-03 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + 'invoice_collection_ids' => [$targetCollection['id']], + 'preview' => true, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($payload['changed'][0]['invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id']) + ->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id']) + ->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe((int)$targetCollection['id']) + ->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id']) + ->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']); +}); + it('splits a selected March and April collected invoice into monthly collections', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'happy'); @@ -139,6 +198,74 @@ it('splits a selected March and April collected invoice into monthly collections } }); +it('splits only explicit monthly split invoice collection ids', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'scope'); + + $customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $targetCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2096-03-01 00:00:01', + ]); + $ignoredCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2096-03-01 00:00:01', + ]); + $targetMarchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $targetCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $targetAprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $targetCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $ignoredMarchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $ignoredCollection['id'], + 'created_at' => '2096-03-16 10:00:00', + ]); + $ignoredAprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $ignoredCollection['id'], + 'created_at' => '2096-04-03 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + 'invoice_collection_ids' => [$targetCollection['id']], + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($aprilCollectionId)->toBeGreaterThan(0) + ->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id']) + ->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId) + ->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id']) + ->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + it('sets closed_at to month end when split month has ended', function (): void { api_test_covers('POST /collected-invoices/split-by-month', 'closed-at'); @@ -345,3 +472,27 @@ it('rejects invalid monthly split date ranges', function (): void { ->assertEnvelope() ->assertSuccess(false); }); + +it('rejects invalid explicit monthly split invoice collection ids', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'invalid-scope'); + + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + 'invoice_collection_ids' => ['not-a-number'], + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); + + api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + 'invoice_collection_ids' => [], + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); +}); From eca7a81f9de714611ac17eb4966229df3b2bd85b Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 13:59:40 +0200 Subject: [PATCH 08/14] Add superuser department overview API --- services/nginx/app/openapi.yaml | 49 +++++++++ .../routes/departmentDailyReportsRoute.php | 47 ++++++++ .../SuperuserDepartmentOverviewApiTest.php | 100 ++++++++++++++++++ .../app/tests/Api/api_coverage_manifest.php | 1 + ...entDailyReportsOverviewOpenApiSpecTest.php | 3 + ...epartmentDailyReportsOverviewRouteTest.php | 2 + 6 files changed, 202 insertions(+) create mode 100644 services/nginx/app/tests/Api/SuperuserDepartmentOverviewApiTest.php diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 77e222e6..2d36eaef 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -12523,6 +12523,40 @@ paths: application/json: schema: {} + /superuser/departments/{id}/overview: + get: + tags: + - Departments + summary: Get superuser department overview + description: Returns the selected department metadata and operational overview metrics for a superuser without requiring scoped department access. + operationId: getSuperuserDepartmentOverview + parameters: + - name: id + in: path + required: true + schema: {type: integer} + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: false + schema: {type: string} + responses: + '200': + description: Superuser department overview loaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserDepartmentOverviewResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /superuser/department/branding: put: tags: @@ -21550,6 +21584,21 @@ components: data: $ref: '#/components/schemas/DepartmentDailyReportOverviewPayload' + SuperuserDepartmentOverviewPayload: + type: object + properties: + department: + $ref: '#/components/schemas/Department' + overview: + $ref: '#/components/schemas/DepartmentDailyReportOverviewPayload' + + SuperuserDepartmentOverviewResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/SuperuserDepartmentOverviewPayload' + DepartmentDailyReportTransactionCountPayload: type: object properties: diff --git a/services/nginx/app/routes/departmentDailyReportsRoute.php b/services/nginx/app/routes/departmentDailyReportsRoute.php index a54e74ec..53042661 100644 --- a/services/nginx/app/routes/departmentDailyReportsRoute.php +++ b/services/nginx/app/routes/departmentDailyReportsRoute.php @@ -812,6 +812,53 @@ class departmentDailyReportsRoute ] ); + $this->get('/superuser/departments/{id}/overview', function () { + global $response; + $this->requirePermission('superuser_fetch_department'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + $department_id_param = (string)($this->fromRoute('id') ?? ''); + if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) { + $response->error('Parameter id must be a positive integer', 400); + return; + } + + self::requireParameters([ + 'date', + ]); + + self::validateDateLocally(); + $date_to = $this->getDate_to(); + $department_id = (int)$department_id_param; + $department = (new departments_o())->select($department_id); + + if (!$department->exists()) { + $response->error('Department not found', 404); + return; + } + + (new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview'); + + $response->success([ + 'department' => $department->asArray(['slack_webhook' => false]), + 'overview' => $this->buildDailyReportOverview( + [$department_id], + (string)self::getParameter('date'), + $date_to + ), + ]); + }, + [ + 'superuser_fetch_department' => 'Get the superuser department overview' + ] + ); + $this->get('/departments/daily-reports/overview', function () { global $response; $this->requirePermission('list_department_daily_reports'); diff --git a/services/nginx/app/tests/Api/SuperuserDepartmentOverviewApiTest.php b/services/nginx/app/tests/Api/SuperuserDepartmentOverviewApiTest.php new file mode 100644 index 00000000..1c892f93 --- /dev/null +++ b/services/nginx/app/tests/Api/SuperuserDepartmentOverviewApiTest.php @@ -0,0 +1,100 @@ +createDepartment([ + 'name' => 'Overview Department ' . uniqid('', false), + 'description' => 'Department overview fixture', + 'economic_department_id' => 42, + 'visible' => 1, + ]); + $departmentRow = api_fixtures()->fetchRowById('departments', (int)$department['id']); + $session = api_fixtures()->createUserSession([ + 'superuser_fetch_department', + ]); + + $response = api_client()->get( + '/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06&date_to=2026-07-06', + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + + expect($payload)->toBeArray(); + expect($payload['department']) + ->toBeArray() + ->toHaveKey('id', (int)$department['id']) + ->toHaveKey('name', $departmentRow['name']) + ->toHaveKey('description', 'Department overview fixture') + ->toHaveKey('economic_department_id', 42); + + expect($payload['overview']) + ->toBeArray() + ->toHaveKey('department_ids', [(int)$department['id']]) + ->toHaveKey('date', '2026-07-06') + ->toHaveKey('date_to', '2026-07-06'); + + expect($payload['overview']['metrics']) + ->toBeArray() + ->toHaveKeys([ + 'bookings', + 'complaints', + 'night_washes', + 'revenue', + 'washes', + 'products_sold', + 'transactions', + 'water_usage', + 'overtime', + ]); + expect($payload['overview']['metrics']['revenue']['state'])->toBe('ready'); + expect($payload['overview']['metrics']['revenue']['value'])->toBe(0); + expect($payload['overview']['products'])->toBeArray(); +}); + +it('rejects superuser department overview requests without permission or valid input', function (): void { + api_test_covers('GET /superuser/departments/{id}/overview', 'auth'); + api_test_covers('GET /superuser/departments/{id}/overview', 'failure'); + + $department = api_fixtures()->createDepartment(); + $unauthorizedSession = api_fixtures()->createUserSession([]); + + api_client()->get( + '/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06', + $unauthorizedSession['headers'] + ) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['superuser_fetch_department']); + + $session = api_fixtures()->createUserSession(['superuser_fetch_department']); + + api_client()->get('/superuser/departments/bad/overview?date=2026-07-06', $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Parameter id must be a positive integer'); + + api_client()->get('/superuser/departments/' . $department['id'] . '/overview', $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing required parameters: date'); + + api_client()->get('/superuser/departments/99999999/overview?date=2026-07-06', $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Department not found'); +}); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index 12b3ff8b..6de06af1 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -19,6 +19,7 @@ return [ 'GET /branding', 'POST /branding', 'PUT /branding', + 'GET /superuser/departments/{id}/overview', 'PUT /superuser/department/branding', 'POST /bird/voice/calls/webhook/inbound', ], diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewOpenApiSpecTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewOpenApiSpecTest.php index 40855994..3cd74066 100644 --- a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewOpenApiSpecTest.php @@ -31,8 +31,11 @@ it('documents the daily report overview endpoint and reusable schemas in openapi $content = department_daily_reports_openapi_content_or_skip(); expect($content)->toContain('/departments/daily-reports/overview:'); + expect($content)->toContain('/superuser/departments/{id}/overview:'); expect($content)->toContain('operationId: getDailyReportOverview'); + expect($content)->toContain('operationId: getSuperuserDepartmentOverview'); expect($content)->toContain('DepartmentDailyReportOverviewResponse:'); + expect($content)->toContain('SuperuserDepartmentOverviewResponse:'); expect($content)->toContain('DepartmentDailyReportMetric:'); expect($content)->toContain('DepartmentDailyReportProductTile:'); expect($content)->toContain('- name: department_ids'); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php index 49af59ec..cb984dbf 100644 --- a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php @@ -342,6 +342,8 @@ it('wires the overview route to batched repository methods and overview path', f $objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php')); expect($routeContent)->toContain('/departments/daily-reports/overview'); + expect($routeContent)->toContain('/superuser/departments/{id}/overview'); + expect($routeContent)->toContain('superuser_fetch_department'); expect($routeContent)->toContain('/departments/daily-reports/complaints'); expect($routeContent)->toContain('outsideHoursStatisticsService'); expect($routeContent)->toContain('dailyReportComplaintsRepository'); From 11c2a1b72eb6b9f4dfbc0511b323564c3abf3787 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 14:06:29 +0200 Subject: [PATCH 09/14] Block restricted customer order items (#296) Co-authored-by: Jeppe Bundgaard --- .../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(); +}); From d345db927f574f6f632b1df81a2a20a623ace019 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 14:16:41 +0200 Subject: [PATCH 10/14] Add daily report table to API test schema --- .../app/tests/Support/Api/ApiSchemaBootstrap.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 6432a005..1189562b 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -111,6 +111,22 @@ CREATE TABLE IF NOT EXISTS `department_variables` ( KEY `idx_department_variables_department_id` (`department_id`), KEY `idx_department_variables_variable` (`variable`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_daily_reports' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_daily_reports` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `water_usage` INT NOT NULL DEFAULT 0, + `water_usage_morning` INT NOT NULL DEFAULT 0, + `notes` TEXT NULL, + `filled_by` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_department_daily_reports_department_id` (`department_id`), + KEY `idx_department_daily_reports_created_at` (`created_at`), + KEY `idx_department_daily_reports_department_created_at` (`department_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'department_gates' => <<<'SQL' CREATE TABLE IF NOT EXISTS `department_gates` ( From 9f797bf6b86c94b796be2e02574681496637f637 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 14:27:39 +0200 Subject: [PATCH 11/14] Add opening hours table to API test schema --- .../tests/Support/Api/ApiSchemaBootstrap.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 1189562b..32e1b8cc 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -127,6 +127,30 @@ CREATE TABLE IF NOT EXISTS `department_daily_reports` ( KEY `idx_department_daily_reports_created_at` (`created_at`), KEY `idx_department_daily_reports_department_created_at` (`department_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_time_bookings_opening_hours' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_time_bookings_opening_hours` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `monday_start` TIME NULL, + `monday_end` TIME NULL, + `tuesday_start` TIME NULL, + `tuesday_end` TIME NULL, + `wednesday_start` TIME NULL, + `wednesday_end` TIME NULL, + `thursday_start` TIME NULL, + `thursday_end` TIME NULL, + `friday_start` TIME NULL, + `friday_end` TIME NULL, + `saturday_start` TIME NULL, + `saturday_end` TIME NULL, + `sunday_start` TIME NULL, + `sunday_end` TIME NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_department_time_bookings_opening_hours_department` (`department`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'department_gates' => <<<'SQL' CREATE TABLE IF NOT EXISTS `department_gates` ( From 8e46ce1b04f248e02c7fec87c4596b698c7153ff Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 14:27:47 +0200 Subject: [PATCH 12/14] [codex] Allow error reports without screenshots (#299) * Allow error reports without screenshots * Stabilize edge gateway shell transcript smoke --------- Co-authored-by: Jeppe Bundgaard --- openapi.yaml | 7 +- scripts/edge-gateway-e2e.mjs | 28 +++--- .../app/classes/error_report_service.php | 89 +++++++++++++++-- services/nginx/app/openapi.yaml | 7 +- .../app/tests/Api/ErrorReportsApiTest.php | 97 +++++++++++++++++++ .../Unit/ErrorReports/ErrorReportTest.php | 2 + 6 files changed, 201 insertions(+), 29 deletions(-) create mode 100644 services/nginx/app/tests/Api/ErrorReportsApiTest.php diff --git a/openapi.yaml b/openapi.yaml index 772ace90..5f5a5af6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -13388,7 +13388,6 @@ components: - expected - actual - data_collection_accepted - - screenshot properties: before_error: type: string @@ -13404,10 +13403,11 @@ components: description: What actually happened data_collection_accepted: type: boolean - description: Required acceptance of collecting screenshot and diagnostic error data + description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached screenshot: type: string - description: PNG, JPEG, or WebP data URI of the current app viewport + nullable: true + description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails. route_path: type: string nullable: true @@ -13518,6 +13518,7 @@ components: nullable: true screenshot: type: object + nullable: true additionalProperties: true answers: type: object diff --git a/scripts/edge-gateway-e2e.mjs b/scripts/edge-gateway-e2e.mjs index 7253e0f5..ae5e49d6 100644 --- a/scripts/edge-gateway-e2e.mjs +++ b/scripts/edge-gateway-e2e.mjs @@ -928,22 +928,20 @@ async function main() { { timeoutMs: 20_000, message: "Browser shell never closed cleanly." } ); - const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, { - token: authToken, - }); - const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions) - ? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || "")) - : []; + await waitForCondition( + async () => { + const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, { + token: authToken, + }); + const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions) + ? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || "")) + : []; + const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []); - assert.ok( - shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")), - "Gateway logs page did not persist the shell transcript." - ); - - const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []); - assert.ok( - timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"), - "Gateway logs page did not include the shell close audit event." + return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")) + && timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"); + }, + { timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." } ); process.stdout.write("Edge gateway E2E smoke completed successfully.\n"); diff --git a/services/nginx/app/classes/error_report_service.php b/services/nginx/app/classes/error_report_service.php index 10507955..d3e32e67 100644 --- a/services/nginx/app/classes/error_report_service.php +++ b/services/nginx/app/classes/error_report_service.php @@ -92,12 +92,17 @@ class error_report_service throw new RuntimeException('Data collection acceptance is required.'); } - $screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? '')); - $storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']); $context = is_array($payload['context'] ?? null) ? $payload['context'] : []; + $storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context); $requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25); $vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25); $runtimeContext = $this->runtimeContext($payload, $context); + $runtimeContext['screenshot_attachment'] = [ + 'status' => $storedScreenshot['status'], + 'attached' => $storedScreenshot['key'] !== '', + 'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null, + 'size_bytes' => (int)$storedScreenshot['size_bytes'], + ]; $this->execute( "INSERT INTO error_reports ( @@ -295,6 +300,67 @@ class error_report_service return $value === true || $value === 1 || $value === '1' || $value === 'true'; } + private function storeOptionalScreenshot(mixed $value, array $context): array + { + if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) { + return $this->emptyScreenshotAttachment('invalid'); + } + + $dataUri = trim((string)($value ?? '')); + if ($dataUri === '') { + return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided'); + } + + try { + $screenshot = self::decodeScreenshotDataUri($dataUri); + } catch (RuntimeException $exception) { + $message = strtolower($exception->getMessage()); + return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid'); + } + + try { + $storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']); + } catch (Throwable) { + return $this->emptyScreenshotAttachment('storage_failed'); + } + + return [ + 'key' => (string)($storedScreenshot['key'] ?? ''), + 'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']), + 'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']), + 'status' => 'stored', + ]; + } + + private function emptyScreenshotAttachment(string $status): array + { + return [ + 'key' => '', + 'mime_type' => '', + 'size_bytes' => 0, + 'status' => $status, + ]; + } + + private function contextScreenshotStatus(array $context): ?string + { + $attachment = $context['screenshot_attachment'] ?? null; + $status = is_array($attachment) ? ($attachment['status'] ?? null) : null; + $status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null; + + return $this->normalizeEmptyScreenshotStatus($status); + } + + private function normalizeEmptyScreenshotStatus(mixed $status): ?string + { + $status = strtolower(trim((string)$status)); + if (in_array($status, ['capture_failed', 'not_provided'], true)) { + return $status; + } + + return null; + } + private function runtimeContext(array $payload, array $context): array { return [ @@ -432,6 +498,10 @@ class error_report_service private function publicReport(array $row, bool $includeDetail): array { + $screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? '')); + $screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0; + $hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0; + $report = [ 'id' => (int)$row['id'], 'status' => (string)$row['status'], @@ -449,10 +519,10 @@ class error_report_service 'release_trace_id' => $row['release_trace_id'] ?? null, 'frontend_version' => $row['frontend_version'] ?? null, 'api_version' => $row['api_version'] ?? null, - 'screenshot' => [ - 'mime_type' => $row['screenshot_mime_type'] ?? null, - 'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0, - ], + 'screenshot' => $hasScreenshot ? [ + 'mime_type' => $screenshotMimeType, + 'size_bytes' => $screenshotSizeBytes, + ] : null, 'answers' => [ 'before_error' => $row['before_error'] ?? '', 'expected' => $row['expected'] ?? '', @@ -467,8 +537,11 @@ class error_report_service ]; if ($includeDetail) { - $report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? '')); - $report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null; + if ($hasScreenshot) { + $objectKey = trim((string)($row['screenshot_object_key'] ?? '')); + $report['screenshot']['url'] = $this->store->screenshotUrl($objectKey); + $report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null; + } $report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null); $report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null); $report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null); diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 77e222e6..dd1674c1 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -13388,7 +13388,6 @@ components: - expected - actual - data_collection_accepted - - screenshot properties: before_error: type: string @@ -13404,10 +13403,11 @@ components: description: What actually happened data_collection_accepted: type: boolean - description: Required acceptance of collecting screenshot and diagnostic error data + description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached screenshot: type: string - description: PNG, JPEG, or WebP data URI of the current app viewport + nullable: true + description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails. route_path: type: string nullable: true @@ -13518,6 +13518,7 @@ components: nullable: true screenshot: type: object + nullable: true additionalProperties: true answers: type: object diff --git a/services/nginx/app/tests/Api/ErrorReportsApiTest.php b/services/nginx/app/tests/Api/ErrorReportsApiTest.php new file mode 100644 index 00000000..91f92f59 --- /dev/null +++ b/services/nginx/app/tests/Api/ErrorReportsApiTest.php @@ -0,0 +1,97 @@ + 'Opening the orders page', + 'expected' => 'The orders should load', + 'actual' => 'The page showed an error', + 'data_collection_accepted' => true, + 'data_collection_policy_version' => 'error-report-v1', + 'route_path' => '/admin/orders', + 'page_url' => 'https://app.example.test/admin/orders', + 'release_trace_id' => 'trace-error-report-test', + 'frontend_version' => 'frontend-test', + 'api_version' => 'api-test', + 'request_errors' => [ + ['method' => 'GET', 'url' => '/orders', 'statusCode' => 500], + ], + 'vue_errors' => [ + ['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']], + ], + 'context' => [ + 'viewport' => ['width' => 1280, 'height' => 720], + 'user_agent' => 'ErrorReportsApiTest', + 'captured_at' => '2026-07-06T10:00:00.000Z', + 'data_collection_policy_version' => 'error-report-v1', + ], + ], $overrides); +} + +function error_report_api_cleanup(array $report): void +{ + $id = (int)($report['id'] ?? 0); + if ($id > 0) { + api_fixtures()->cleanupDeleteById('error_reports', $id); + } +} + +it('creates error reports when screenshot capture failed', function (): void { + api_test_covers('POST /error-reports', 'happy'); + + $session = api_fixtures()->createUserSession(); + $response = api_client()->post('/error-reports', error_report_api_payload([ + 'screenshot' => null, + 'context' => [ + 'screenshot_attachment' => ['status' => 'capture_failed'], + ], + ]), $session['headers']); + + $response + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $report = $response->data(); + expect($report['screenshot'])->toBeNull(); + expect($report['answers']['before_error'])->toBe('Opening the orders page'); + expect($report['request_error_count'])->toBe(1); + expect($report['vue_error_count'])->toBe(1); + expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([ + 'status' => 'capture_failed', + 'attached' => false, + 'mime_type' => null, + 'size_bytes' => 0, + ]); + + error_report_api_cleanup($report); +}); + +it('creates error reports when an optional screenshot payload is invalid', function (): void { + api_test_covers('POST /error-reports', 'invalid optional screenshot'); + + $session = api_fixtures()->createUserSession(); + $response = api_client()->post('/error-reports', error_report_api_payload([ + 'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'), + ]), $session['headers']); + + $response + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $report = $response->data(); + expect($report['screenshot'])->toBeNull(); + expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([ + 'status' => 'invalid', + 'attached' => false, + 'mime_type' => null, + 'size_bytes' => 0, + ]); + + error_report_api_cleanup($report); +}); diff --git a/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php index b4fa2684..ffc678e6 100644 --- a/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php +++ b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php @@ -76,4 +76,6 @@ it('defines error report schema, routes, permissions, storage, and OpenAPI docs' expect($openapi)->toContain('/error-reports:'); expect($openapi)->toContain('ErrorReportSubmissionRequest'); expect($openapi)->toContain('ErrorReportStatusUpdateRequest'); + expect($openapi)->not->toContain(" - screenshot\n"); + expect($openapi)->toContain('Reports are accepted without an attachment when capture or upload fails.'); }); From d9eacf6f8410f419aef260c720ab442a8d35473f Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 16:28:08 +0200 Subject: [PATCH 13/14] Deduplicate limited backoffice price products --- .../classes/limited_backoffice_service.php | 8 +- .../tests/Api/LimitedBackofficeApiTest.php | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index 6a3b8c0f..ab92625e 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -869,9 +869,15 @@ class limited_backoffice_service $categories = []; $missing = []; $requiredProductIds = []; + $seenProductIds = []; foreach ($rows as $row) { $categoryId = (int)$row['category_id']; $productId = (int)$row['product_id']; + + if (isset($seenProductIds[$productId])) { + continue; + } + $seenProductIds[$productId] = true; $requiredProductIds[] = $productId; if (!isset($categories[$categoryId])) { @@ -911,7 +917,7 @@ class limited_backoffice_service return [ 'categories' => array_values($categories), 'missing_products' => $missing, - 'required_product_ids' => array_values(array_unique($requiredProductIds)), + 'required_product_ids' => array_values($requiredProductIds), ]; } diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index 8f2d8e76..b9c4d6d0 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -211,6 +211,68 @@ it('updates department prices when the price table has no updated_at column', fu }); }); +it('deduplicates products from duplicate department category links', function (): void { + api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Products']); + $category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Category']); + $firstProduct = api_fixtures()->createProduct([ + 'name' => 'Duplicate Price A', + 'category' => $category['id'], + ]); + $secondProduct = api_fixtures()->createProduct([ + 'name' => 'Duplicate Price B', + 'category' => $category['id'], + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + limited_backoffice_price_insert((int)$department['id'], (int)$firstProduct['id'], 111); + limited_backoffice_price_insert((int)$department['id'], (int)$secondProduct['id'], 222); + + $session = limited_backoffice_manager_session([(int)$department['id']]); + + $response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']); + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $productIds = []; + foreach ($response->data()['categories'] as $departmentCategory) { + foreach ($departmentCategory['products'] as $departmentProduct) { + $productIds[] = (int)$departmentProduct['id']; + } + } + + expect($productIds)->toBe([(int)$firstProduct['id'], (int)$secondProduct['id']]); +}); + +it('deduplicates missing product setup gaps from duplicate department category links', function (): void { + api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe failure'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Setup Gap']); + $category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Setup Category']); + $product = api_fixtures()->createProduct([ + 'name' => 'Duplicate Missing Product', + 'category' => $category['id'], + 'price' => 88888, + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + + $session = limited_backoffice_manager_session([(int)$department['id']]); + + $response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']); + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Department price setup is incomplete.'); + + expect(array_column($response->data()['missing_products'], 'id'))->toBe([(int)$product['id']]); + expect($response->body)->not->toContain('88888'); +}); + it('rejects cross-department price access, body spoofing, and outside products', function (): void { api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'auth'); api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'auth'); @@ -392,6 +454,21 @@ it('rejects invalid price batches and leaves existing prices unchanged', functio expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200); } + api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [ + 'prices' => [ + ['product_id' => (int)$firstProduct['id'], 'price' => 999], + ['product_id' => (int)$firstProduct['id'], 'price' => 888], + ['product_id' => (int)$secondProduct['id'], 'price' => 777], + ], + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Duplicate product price rows are not allowed.'); + + expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100); + expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200); + api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [ 'prices' => [ ['product_id' => (int)$firstProduct['id'], 'price' => 999], From 2ae1fc3fcf23d92e006e2148ae56e68d9f769c74 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 16:33:31 +0200 Subject: [PATCH 14/14] Allow customer order booking creation without booking permission --- .../nginx/app/routes/departmentsRoute.php | 20 ++- .../nginx/app/routes/orderBookingRoute.php | 44 ++++-- .../app/tests/Api/DepartmentsApiTest.php | 36 +++++ .../tests/Api/OrderBookingsCreateApiTest.php | 144 ++++++++++++++++++ .../nginx/app/tests/Api/OrderItemsApiTest.php | 6 +- 5 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index 5cb5692f..d28d128e 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -250,11 +250,17 @@ class departmentsRoute $this->get('/departments/categories', function () { // Require the user to be logged in global $response; - self::requirePermission('list_department_categories'); - // Get the user object - $user = (new authentication())->get_user(); + $auth = new authentication(); + $user = $auth->get_user(); + $subuser = $auth->get_subuser(); // Check if the request was successful - if ($user) { + if ($user || $subuser) { + $isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser; + if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) { + $this->emitForbidden(['list_department_categories']); + } + + $responsibleUserId = $user ? (int)$user->id : 0; // Require the department id self::requireParameters(['id']); self::requireType((int)self::getParameter('id'), self::TYPE_INT()); @@ -263,14 +269,14 @@ class departmentsRoute // Validate the department categories object if (!$department->exists()) { // Log the incident - (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found'); + (new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found'); // Return an error $response->error('Department categories not found', 400); } // Get the department categories $department_categories = new department_categories_o(); // Log the incident - (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories'); + (new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories'); // Return the list of department categories $response->success( $department_categories @@ -295,7 +301,7 @@ class departmentsRoute } }, [ - 'list_department_categories' => 'List all department categories' + 'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.' ] ); diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index 9e3ba47d..264749d6 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -41,18 +41,9 @@ class orderBookingRoute $po = self::getTargetPo(); // String | Null $pickup = self::getTargetPickup(); // Bool | Null $items = self::getTargetItems(); // Array of order_items_o objects - /** - * Permissions (clean helper) - */ - $permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD); - $permission_other = self::definePermission('add_bookings'); - self::allowOwnOrDepartmentAccess( - $permission_own, - $permission_other, + $this->requireOrderBookingCreateAccess( (int)$customer_number->customer_number->value(), - (int)$department->id, - null, - 'You do not have permission to create this order booking.' + (int)$department->id ); /** * Input data @@ -96,8 +87,7 @@ class orderBookingRoute $response->success($order_bookings_o->asArray()); }, [ - 'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.', - 'add_bookings' => 'Permission to create department order bookings.' + 'add_bookings' => 'Permission to create order bookings for another customer or department scope.' ] ); @@ -659,6 +649,34 @@ class orderBookingRoute return $object; } + private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void + { + if ($this->isOrderBookingCustomerSession() && $this->isOwnCustomerContext($targetCustomerNumber)) { + return; + } + + $permissionOther = self::definePermission('add_bookings'); + if (!self::hasPermission($permissionOther)) { + $this->emitForbidden([$permissionOther]); + } + + self::requireDepartmentAccess((string)$departmentId); + } + + private function isOrderBookingCustomerSession(): bool + { + try { + $auth = new authentication(); + if ($auth->get_subuser() !== false) { + return true; + } + + return $auth->get_user() !== false && self::hasPermission('user'); + } catch (Exception) { + return false; + } + } + /** * @throws Exception If the Department is invalid. */ diff --git a/services/nginx/app/tests/Api/DepartmentsApiTest.php b/services/nginx/app/tests/Api/DepartmentsApiTest.php index 968e82dc..9cde9e41 100644 --- a/services/nginx/app/tests/Api/DepartmentsApiTest.php +++ b/services/nginx/app/tests/Api/DepartmentsApiTest.php @@ -301,6 +301,42 @@ it('lists department categories for a department', function (): void { ->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']); }); +it('lets customer booking sessions list department categories without the management permission', function (): void { + api_test_covers('GET /departments/categories', 'auth'); + + $customerSession = api_fixtures()->createUserSession(['user']); + $department = api_fixtures()->createDepartment(); + $category = api_fixtures()->createCategory([ + 'name' => 'Customer Department Category', + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + + $customerResponse = api_client()->get('/departments/categories?id=' . $department['id'], $customerSession['headers']); + + $customerResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($customerResponse->data()) + ->toBeArray() + ->toHaveCount(1) + ->and($customerResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']); + + $subuserSession = api_fixtures()->createSubuserSession((int)$customerSession['user']['customer_number'], []); + $subuserResponse = api_client()->get('/departments/categories?id=' . $department['id'], $subuserSession['headers']); + + $subuserResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($subuserResponse->data()) + ->toBeArray() + ->toHaveCount(1) + ->and($subuserResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']); +}); + it('rejects invalid department category requests', function (): void { api_test_covers('GET /departments/categories', 'failure'); diff --git a/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php new file mode 100644 index 00000000..1960ed65 --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php @@ -0,0 +1,144 @@ + (int)$customer['customer_number'], + 'department' => (int)$department['id'], + 'reg_1' => $reference, + 'datetime' => '2026-07-07 10:00:00', + 'note' => '', + 'reference' => $reference, + 'po' => '', + 'pickup' => false, + 'items' => [ + [ + 'id' => (int)$product['id'], + 'quantity' => 1, + ], + ], + ]; +} + +function order_booking_create_department(string $name): array +{ + $branding = api_fixtures()->createBranding([ + 'name' => $name . ' Brand', + 'address' => 'API Booking Street 1', + ]); + + return api_fixtures()->createDepartment([ + 'name' => $name, + 'branding' => (int)$branding['id'], + ]); +} + +it('lets customers create their own order bookings without booking permissions', function (): void { + api_test_covers('POST /order-bookings', 'auth'); + + $session = api_fixtures()->createUserSession(['user']); + $department = order_booking_create_department('Own Booking Department'); + $product = api_fixtures()->createProduct(['name' => 'Own Booking Product']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($session['user'], $department, $product, 'OWNBOOK1'), + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $bookingId = (int)($response->data()['id'] ?? 0); + expect($bookingId)->toBeGreaterThan(0); + + $row = api_fixtures()->fetchRowById('order_bookings', $bookingId); + expect($row)->not->toBeNull(); + expect((int)($row['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']); + + api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); +}); + +it('lets subusers create own customer order bookings without the bookings add node', function (): void { + api_test_covers('POST /order-bookings', 'auth'); + + $customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']); + $session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], []); + $department = order_booking_create_department('Subuser Booking Department'); + $product = api_fixtures()->createProduct(['name' => 'Subuser Booking Product']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($customer, $department, $product, 'SUBBOOK1'), + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $bookingId = (int)($response->data()['id'] ?? 0); + expect($bookingId)->toBeGreaterThan(0); + + $row = api_fixtures()->fetchRowById('order_bookings', $bookingId); + expect($row)->not->toBeNull(); + expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']); + + api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); +}); + +it('still requires elevated access for creating another customer order booking', function (): void { + api_test_covers('POST /order-bookings', 'auth'); + + $session = api_fixtures()->createUserSession(['user']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']); + $department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']); + $product = api_fixtures()->createProduct(['name' => 'Other Booking Product']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'), + $session['headers'] + ); + + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['add_bookings']); +}); + +it('lets department-scoped users create order bookings for another customer', function (): void { + api_test_covers('POST /order-bookings', 'happy'); + + $customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']); + $department = order_booking_create_department('Department Scoped Booking Department'); + $product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']); + $session = api_fixtures()->createUserSession([ + 'add_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'), + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $bookingId = (int)($response->data()['id'] ?? 0); + expect($bookingId)->toBeGreaterThan(0); + + api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); +}); diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 869ff044..edf1be02 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -147,7 +147,7 @@ it('only allows tankcleaning products for only tankcleaning customers', function ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) - ->assertMessage(\classes\customer_order_product_policy::ONLY_TANKCLEANING_MESSAGE); + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); $response = api_client()->post('/order/items', [ 'order_id' => $order['id'], @@ -274,7 +274,9 @@ it('blocks addon products added as standalone additional order items for custome ->assertEnvelope() ->assertSuccess(); - post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers']) + post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [ + 'notes' => 'Addon customer rule check', + ]) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false)