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/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/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/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/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/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/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index ca71d207..3c47c04d 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -689,6 +689,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, @@ -722,6 +723,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 @@ -1923,12 +1925,19 @@ class invoice_period_flag_service private function calculateExpectedPrice(array $row): int { + $customMissingPrice = $this->isCustomMissingDepartmentPrice($row); + if ($customMissingPrice) { + return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE; + } + $fixedPrice = $this->rowProductFixedPrice($row); if ($fixedPrice !== null) { return $fixedPrice; } - $base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0); + $base = $row['department_price'] !== null + ? (int)$row['department_price'] + : (int)($row['product_base_price'] ?? 0); $discount = $this->discountBreakdown($row)['applied_discount_percentage']; return (int)round($base * (1 - ($discount / 100))); } @@ -1936,11 +1945,15 @@ class invoice_period_flag_service 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_fixed_price' => $this->rowProductFixedPrice($row), @@ -1952,6 +1965,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); @@ -2054,8 +2072,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/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index 1fdeac3a..ab92625e 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 @@ -112,6 +113,134 @@ 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 + */ + private const PHONE_COUNTRY_CODES = [ + 45 => true, + 46 => true, + 47 => true, + 358 => true, + ]; + /** * @var array */ @@ -119,11 +248,12 @@ class limited_backoffice_service public function __construct() { + departments_schema_bootstrap::ensureTables(); limited_backoffice_schema_bootstrap::ensureTables(); } /** - * @return array + * @return array}>}> */ public function rolePresets(): array { @@ -133,11 +263,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 */ @@ -194,7 +358,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 +373,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 +389,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 +423,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 +441,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, @@ -382,7 +549,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(); @@ -393,13 +561,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(); @@ -477,11 +655,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); @@ -511,6 +690,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; @@ -611,13 +794,13 @@ class limited_backoffice_service } /** - * @return array{id:int,name:string,description:string} + * @return array{id:int,name:string,description:string,custom_pricing_only:bool} */ private function fetchDepartment(int $departmentId): ?array { 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); @@ -636,13 +819,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; @@ -685,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])) { @@ -703,10 +893,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'], @@ -725,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), ]; } @@ -909,6 +1101,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 === '') { @@ -1084,6 +1359,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), @@ -1204,7 +1481,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/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/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 e8746659..ba4bffc6 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,12 +169,17 @@ 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()); + $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); - $price = $customer->applyProductCustomerPricing($product_id, (int)$price, false); + if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { + $price = $customer->applyProductCustomerPricing($product_id, (int)$price, false); + } // If the price is forced, set the price to the forced price if ($forcePrice !== null) { diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index fa4a1598..3de215b6 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1428,13 +1428,16 @@ 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()) { throw new Exception('No user found matching the customer number in the usage log'); } - $product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price); + if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { + $product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$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 @@ -1501,7 +1504,11 @@ 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 + if (products_o::priceResolutionIsCustomMissing($priceResolution)) { + return $price; + } return $current_user->applyProductCustomerPricing((int)$product->id, $price); } @@ -1584,12 +1591,16 @@ 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()); } - $post_discount = $tmp_user->applyProductCustomerPricing($product_id, (int)$department_price_cache[$product_id], false) * $quantity; + $unitPrice = (int)$department_price_cache[$product_id]['price']; + if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) { + $unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false); + } + $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 149b31a9..83dc6e1a 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 @@ -300,10 +339,26 @@ class products_o extends db if (!isset($product['id']) || !isset($product['price'])) { throw new \InvalidArgumentException('Invalid product array, must contain id and price keys'); } - $product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']); + if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) { + $product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']); + } + 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/openapi.yaml b/services/nginx/app/openapi.yaml index d9619476..6395f13f 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -12528,6 +12528,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: @@ -13393,7 +13427,6 @@ components: - expected - actual - data_collection_accepted - - screenshot properties: before_error: type: string @@ -13409,10 +13442,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 @@ -13523,6 +13557,7 @@ components: nullable: true screenshot: type: object + nullable: true additionalProperties: true answers: type: object @@ -21555,6 +21590,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/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 57f08f4e..a546c02c 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -1730,10 +1730,14 @@ 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->applyProductCustomerPricing($product_id, (int)$department_price_cache[$department_id][$product_id], false); + $unit_price = (int)$department_price_cache[$department_id][$product_id]['price']; + if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) { + $unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false); + } + $discount_cache[$product_id] = $unit_price; } $post_discount = (int)$discount_cache[$product_id] * $quantity; $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount); 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/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index b96743cd..d28d128e 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'); @@ -240,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()); @@ -253,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 @@ -285,7 +301,7 @@ class departmentsRoute } }, [ - 'list_department_categories' => 'List all department categories' + 'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.' ] ); 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/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/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index 21454f33..c6786290 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/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/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); +}); diff --git a/services/nginx/app/tests/Api/DepartmentsApiTest.php b/services/nginx/app/tests/Api/DepartmentsApiTest.php index 8c68c24a..9cde9e41 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 { @@ -299,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/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/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index 55391471..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'); @@ -285,6 +347,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'); @@ -313,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], @@ -348,11 +504,60 @@ 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', 'email' => 'limited-cashier@example.test', + 'phone_country_code' => 45, + 'phone' => 12345678, 'password' => 'Secret123!', 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], @@ -366,6 +571,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'); @@ -384,6 +592,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']); @@ -393,11 +604,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']); @@ -419,6 +644,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'); @@ -429,6 +682,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']], @@ -440,6 +694,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']], @@ -452,6 +707,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']], @@ -504,3 +760,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.'); +}); 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 15f559dc..0088ee9a 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -4,6 +4,73 @@ 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); +} + +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'); @@ -15,7 +82,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, @@ -110,6 +176,85 @@ it('uses a product fixed price instead of the best discount when adding an order expect($response->data()['price'] ?? null)->toBe(350); }); +it('only allows tankcleaning products for only tankcleaning customers', function (): void { + api_test_covers('POST /order/items', 'customer_rules'); + + $customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']); + api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning'); + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'ONLY-TANK', + ]); + $washProduct = api_fixtures()->createProduct([ + 'name' => 'Forvogn', + 'price' => 649, + 'category' => 4, + ]); + $tankCleaningProduct = api_fixtures()->createProduct([ + 'name' => 'Saebe/kemi, 1-4 spulehoveder', + 'price' => 299, + 'category' => 5, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + api_client() + ->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $washProduct['id'], + 'quantity' => 1, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $tankCleaningProduct['id'], + 'quantity' => 1, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']); +}); + +it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void { + api_test_covers('POST /order/items', 'customer_rules'); + + $customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']); + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'REGULAR-WASH', + ]); + $washProduct = api_fixtures()->createProduct([ + 'name' => 'Forvogn', + 'price' => 649, + 'category' => 4, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $washProduct['id'], + 'quantity' => 1, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']); +}); + it('does not allow clearing notes for order items whose product requires notes', function (): void { api_test_covers('PUT /order/items', 'validation'); @@ -156,7 +301,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, @@ -172,3 +316,202 @@ 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'], [ + 'notes' => 'Addon customer rule check', + ]) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); +}); + +it('allows standalone additional order items when the customer is not restricted from additional services', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(); + $primaryProduct = api_fixtures()->createProduct([ + 'name' => 'Primary unrestricted truck wash', + 'price' => 200, + ]); + $addonProduct = api_fixtures()->createProduct([ + 'name' => 'Unrestricted add-on', + 'category' => 4, + 'price' => 50, + ]); + + post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); +}); + +it('blocks related addon order items for customers restricted from additional services', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['restrictAdditionalServices']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']); + $primaryProduct = api_fixtures()->createProduct([ + 'name' => 'Primary related truck wash', + 'price' => 200, + ]); + $addonProduct = api_fixtures()->createProduct([ + 'name' => 'Related extra brush', + 'price' => 35, + ]); + $primaryItem = api_fixtures()->createOrderItem([ + 'order_id' => $fixture['order']['id'], + 'product_id' => $primaryProduct['id'], + 'cashier_id' => $cashier['id'], + 'price' => 200, + ]); + + post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [ + 'related_item_id' => $primaryItem['id'], + ]) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); +}); + +it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture([$attribute]); + $product = api_fixtures()->createProduct($productAttributes); + + post_order_item($fixture['order'], $product, $fixture['session']['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); +})->with([ + 'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]], + 'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]], + 'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]], +]); + +it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['onlyTankCleaning']); + $nonTankProduct = api_fixtures()->createProduct([ + 'name' => 'Exterior truck wash', + 'price' => 180, + ]); + $tankProduct = api_fixtures()->createProduct([ + 'name' => 'Tank cleaning', + 'category' => 5, + 'price' => 300, + ]); + + post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); + + post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); +}); + +it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void { + api_test_covers('GET /products', 'happy'); + api_test_covers('POST /order/items', 'happy'); + + $department = api_fixtures()->createDepartment([ + 'name' => 'Custom Pricing Products', + 'custom_pricing_only' => 1, + ]); + $otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']); + $category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']); + $product = api_fixtures()->createProduct([ + 'name' => 'Custom Pricing Missing Product', + 'category' => $category['id'], + 'price' => 12345, + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']); + custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333); + + $customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']); + api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0); + custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50); + + $session = api_fixtures()->createUserSession([ + 'list_products', + 'add_order_items', + 'department_access_' . (int)$department['id'], + ]); + + $productResponse = api_client()->get( + '/products?final_price=true&id=' . (int)$product['id'] + . '&department_id=' . (int)$department['id'] + . '&customer_id=' . (int)$customer['customer_number'], + $session['headers'] + ); + $productResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($productResponse->body)->not->toContain('12345'); + expect($productResponse->body)->not->toContain('3333'); + expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); + + api_client()->get( + '/products?final_price=true&id=' . (int)$product['id'] + . '&department_id=' . (int)$otherDepartment['id'], + $session['headers'] + ) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]); + + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'CUSTOM-ONLY-ORDER', + ]); + + $orderItem = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + ], $session['headers']); + + $orderItem + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); +}); 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/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 9456b2ef..16a6a3dd 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -134,6 +134,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), @@ -1738,6 +1739,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', [ diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 42eaa802..24b12b43 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -90,6 +90,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, @@ -112,6 +113,46 @@ 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_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` ( @@ -894,6 +935,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/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'); 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.'); }); 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); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index 922f20e5..84c58800 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -645,6 +645,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, 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(); +});