diff --git a/openapi.yaml b/openapi.yaml index e6f14a86..7d04402a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12656,6 +12656,139 @@ paths: application/json: schema: {} + /superuser/department/customer-pricing: + get: + tags: + - Departments + summary: Get department-specific customer pricing + description: Returns customer price overrides and the department product catalog when custom-only pricing is enabled for the department. + operationId: getSuperuserDepartmentCustomerPricing + parameters: + - name: department_id + in: query + required: true + schema: + type: integer + minimum: 1 + - name: user_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department customer pricing + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + put: + tags: + - Departments + summary: Replace department-specific customer pricing + description: Replaces the complete override set for one customer in one custom-only department. + operationId: setSuperuserDepartmentCustomerPricing + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingUpdateRequest' + responses: + '200': + description: Department customer pricing updated + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + + /limited-backoffice/departments/{departmentId}/customer-pricing: + get: + tags: + - Limited Backoffice + summary: Get limited-backoffice department customer pricing + description: Returns department-specific customer pricing for an assigned custom-only department. + operationId: getLimitedBackofficeDepartmentCustomerPricing + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + - name: user_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department customer pricing + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + put: + tags: + - Limited Backoffice + summary: Replace limited-backoffice department customer pricing + description: Replaces the complete override set for one customer in an assigned custom-only department. + operationId: setLimitedBackofficeDepartmentCustomerPricing + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingLimitedUpdateRequest' + responses: + '200': + description: Department customer pricing updated + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + /superuser/department/variables: get: tags: @@ -13471,6 +13604,164 @@ components: type: integer description: HTTP status code + DepartmentCustomerPricingUpdateRequest: + type: object + required: + - department_id + - user_id + - overrides + properties: + department_id: + type: integer + minimum: 1 + user_id: + type: integer + minimum: 1 + overrides: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingOverrideInput' + + DepartmentCustomerPricingLimitedUpdateRequest: + type: object + required: + - user_id + - overrides + properties: + user_id: + type: integer + minimum: 1 + overrides: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingOverrideInput' + + DepartmentCustomerPricingOverrideInput: + type: object + required: + - is_category + - product_or_category_id + properties: + is_category: + type: boolean + product_or_category_id: + oneOf: + - type: integer + - type: string + description: Product id, category id, or `global` for the customer-wide department discount. + discount: + type: integer + minimum: 0 + maximum: 100 + percentage: + type: integer + minimum: 0 + maximum: 100 + fixed_price: + type: integer + nullable: true + minimum: 0 + + DepartmentCustomerPricingOverride: + allOf: + - $ref: '#/components/schemas/DepartmentCustomerPricingOverrideInput' + - type: object + properties: + id: + type: integer + department_id: + type: integer + user_id: + type: integer + percentage: + type: integer + minimum: 0 + maximum: 100 + created_at: + type: string + updated_at: + type: string + + DepartmentCustomerPricingProduct: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + category: + type: integer + apply_category_discount: + type: boolean + base_price: + type: integer + department_price: + type: integer + nullable: true + effective_price: + type: integer + missing_department_price: + type: boolean + + DepartmentCustomerPricingCategory: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + products: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingProduct' + + DepartmentCustomerPricingResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + department: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + custom_pricing_only: + type: boolean + customer: + type: object + properties: + id: + type: integer + customer_number: + type: integer + display_name: + type: string + overrides: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingOverride' + categories: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingCategory' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + ErrorReportSubmissionRequest: type: object required: diff --git a/services/nginx/app/classes/department_customer_price_overrides_schema_bootstrap.php b/services/nginx/app/classes/department_customer_price_overrides_schema_bootstrap.php new file mode 100644 index 00000000..723d00ee --- /dev/null +++ b/services/nginx/app/classes/department_customer_price_overrides_schema_bootstrap.php @@ -0,0 +1,44 @@ +query( + "CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `user_id` INT NOT NULL, + `is_category` TINYINT(1) NOT NULL DEFAULT 0, + `product_or_category_id` VARCHAR(191) NOT NULL, + `percentage` INT NOT NULL DEFAULT 0, + `fixed_price` INT NULL DEFAULT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`), + KEY `idx_department_customer_price_overrides_department` (`department_id`), + KEY `idx_department_customer_price_overrides_user` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + self::$initialized = true; + } +} diff --git a/services/nginx/app/classes/department_customer_pricing_service.php b/services/nginx/app/classes/department_customer_pricing_service.php new file mode 100644 index 00000000..337f7ca3 --- /dev/null +++ b/services/nginx/app/classes/department_customer_pricing_service.php @@ -0,0 +1,353 @@ + + */ + public function getPricing(int $departmentId, int $userId): array + { + $department = $this->department($departmentId); + $customer = $this->customer($userId); + $this->assertEnabled($department); + + $overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId); + + return [ + 'department' => $department, + 'customer' => $customer, + 'overrides' => $overrides, + 'categories' => $this->catalog($departmentId, $customer['id']), + ]; + } + + /** + * @param array $payload + * @return array + */ + public function updatePricing(int $departmentId, int $userId, array $payload): array + { + $department = $this->department($departmentId); + $customer = $this->customer($userId); + $this->assertEnabled($department); + + if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) { + throw new limited_backoffice_exception('Department ID in body does not match the route.', 400); + } + if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) { + throw new limited_backoffice_exception('User ID in body does not match the route.', 400); + } + + $overrides = $payload['overrides'] ?? null; + if (!is_array($overrides)) { + throw new limited_backoffice_exception('Overrides are required.', 400); + } + + $normalized = $this->normalizeOverrides($departmentId, $overrides); + $overrideObject = new department_customer_price_overrides_o(); + $existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']); + $normalizedKeys = []; + foreach ($normalized as $override) { + $normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true; + } + + global $db; + $db->conn()->begin_transaction(); + try { + $db->query( + 'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ' + . (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id'] + ); + + foreach ($normalized as $override) { + $overrideObject->setPrice( + $departmentId, + $customer['id'], + (bool)$override['is_category'], + $override['product_or_category_id'], + (int)$override['percentage'], + $override['fixed_price'] + ); + } + + $db->conn()->commit(); + } catch (\Throwable) { + $db->conn()->rollback(); + throw new limited_backoffice_exception('Unable to update department customer pricing.', 500); + } + + foreach ($normalized as $override) { + $this->recordVersion($customer, $departmentId, $override); + } + + foreach ($existingOverrides as $existingOverride) { + $key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']); + if (isset($normalizedKeys[$key])) { + continue; + } + + $this->recordVersion($customer, $departmentId, [ + 'is_category' => (bool)$existingOverride['is_category'], + 'product_or_category_id' => $existingOverride['product_or_category_id'], + 'percentage' => 0, + 'fixed_price' => null, + ]); + } + + return $this->getPricing($departmentId, $customer['id']); + } + + /** + * @return array{id:int,name:string,description:string,custom_pricing_only:bool} + */ + private function department(int $departmentId): array + { + $department = (new departments_o())->getDepartmentById($departmentId); + if (!is_array($department) || empty($department)) { + throw new limited_backoffice_exception('Department not found', 404); + } + + return [ + 'id' => (int)$department['id'], + 'name' => (string)$department['name'], + 'description' => (string)($department['description'] ?? ''), + 'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0), + ]; + } + + /** + * @return array{id:int,customer_number:int,display_name:string} + */ + private function customer(int $userId): array + { + $customer = (new users_o())->getUserById($userId); + if (!$customer->exists()) { + throw new limited_backoffice_exception('Customer not found', 404); + } + + return [ + 'id' => (int)$customer->id, + 'customer_number' => (int)$customer->customer_number->value(), + 'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())), + ]; + } + + /** + * @param array $department + */ + private function assertEnabled(array $department): void + { + if (!($department['custom_pricing_only'] ?? false)) { + throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [ + 'message' => 'Department customer pricing is disabled.', + 'code' => 'department_customer_pricing_disabled', + 'department' => $department, + ]); + } + } + + /** + * @return array> + */ + private function catalog(int $departmentId, int $userId): array + { + global $db; + + $sql = " + SELECT + c.`id` AS `category_id`, + c.`name` AS `category_name`, + c.`description` AS `category_description`, + p.*, + pdp.`price` AS `department_price` + FROM `department_categories` dc + INNER JOIN `categories` c ON c.`id` = dc.`category_id` + INNER JOIN `products` p ON p.`category` = dc.`category_id` + LEFT JOIN `product_department_prices` pdp + ON pdp.`department_id` = dc.`department_id` + AND pdp.`product_id` = p.`id` + WHERE dc.`department_id` = " . (int)$departmentId . " + AND dc.`deleted_at` IS NULL + ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC"; + + $result = $db->query($sql); + $rows = $result ? $db->fetch_all($result) : []; + $customer = (new users_o())->getUserById($userId); + $categories = []; + $seen = []; + + foreach ($rows as $row) { + $productId = (int)$row['id']; + if (isset($seen[$productId])) { + continue; + } + $seen[$productId] = true; + + $categoryId = (int)$row['category_id']; + if (!isset($categories[$categoryId])) { + $categories[$categoryId] = [ + 'id' => $categoryId, + 'name' => (string)$row['category_name'], + 'description' => (string)($row['category_description'] ?? ''), + 'products' => [], + ]; + } + + $departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price']; + $effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE; + if ($departmentPrice !== null) { + $effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId); + } + + $categories[$categoryId]['products'][] = [ + 'id' => $productId, + 'name' => (string)$row['name'], + 'description' => (string)($row['description'] ?? ''), + 'category' => $categoryId, + 'apply_category_discount' => (bool)$row['apply_category_discount'], + 'base_price' => (int)$row['price'], + 'department_price' => $departmentPrice, + 'effective_price' => $effectivePrice, + 'missing_department_price' => $departmentPrice === null, + ]; + } + + return array_values($categories); + } + + /** + * @param array $overrides + * @return array + */ + private function normalizeOverrides(int $departmentId, array $overrides): array + { + $normalized = []; + foreach ($overrides as $override) { + if (!is_array($override)) { + throw new limited_backoffice_exception('Invalid override payload.', 400); + } + + $isCategory = (bool)($override['is_category'] ?? false); + $objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null; + if ($objectId === null || $objectId === '') { + throw new limited_backoffice_exception('Override object is required.', 400); + } + + $percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT); + if ($percentage === false || $percentage < 0 || $percentage > 100) { + throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400); + } + + $fixedPrice = null; + if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') { + $fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT); + if ($fixedPrice === false || $fixedPrice < 0) { + throw new limited_backoffice_exception('Fixed price must be zero or more.', 400); + } + } + + if ($isCategory) { + $fixedPrice = null; + $objectId = (string)$objectId; + if ($objectId !== 'global') { + $this->assertDepartmentCategory($departmentId, $objectId); + } + } else { + $objectId = (int)$objectId; + $this->assertDepartmentProduct($departmentId, $objectId); + } + + if ($percentage <= 0 && $fixedPrice === null) { + continue; + } + + $key = $this->overrideKey($isCategory, $objectId); + $normalized[$key] = [ + 'is_category' => $isCategory, + 'product_or_category_id' => $objectId, + 'percentage' => (int)$percentage, + 'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice, + ]; + } + + return array_values($normalized); + } + + /** + * @param array{id:int,customer_number:int,display_name:string} $customer + * @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override + */ + private function recordVersion(array $customer, int $departmentId, array $override): void + { + try { + (new economic_v2_versioning_service())->recordDiscountOverrideVersion( + (int)$customer['id'], + (int)$customer['customer_number'], + (bool)$override['is_category'], + (string)$override['product_or_category_id'], + (int)$override['percentage'], + date('Y-m-d H:i:s'), + 'live.department_discount_override.route', + 1.0, + false, + [ + 'route' => 'department_customer_pricing', + 'department_id' => $departmentId, + ], + $override['fixed_price'], + $departmentId + ); + } catch (\Throwable) { + } + } + + private function overrideKey(bool $isCategory, int|string $objectId): string + { + return ((int)$isCategory) . ':' . (string)$objectId; + } + + private function assertDepartmentProduct(int $departmentId, int $productId): void + { + global $db; + + $result = $db->query( + 'SELECT p.`id` + FROM `department_categories` dc + INNER JOIN `products` p ON p.`category` = dc.`category_id` + WHERE dc.`department_id` = ' . (int)$departmentId . ' + AND dc.`deleted_at` IS NULL + AND p.`id` = ' . (int)$productId . ' + LIMIT 1' + ); + + if (!$result || $result->num_rows < 1) { + throw new limited_backoffice_exception('Product is not available for this department.', 400); + } + } + + private function assertDepartmentCategory(int $departmentId, string $categoryId): void + { + global $db; + + $categoryId = $db->escape_string($categoryId); + $result = $db->query( + "SELECT `id` + FROM `department_categories` + WHERE `department_id` = " . (int)$departmentId . " + AND `deleted_at` IS NULL + AND `category_id` = '{$categoryId}' + LIMIT 1" + ); + + if (!$result || $result->num_rows < 1) { + throw new limited_backoffice_exception('Category is not available for this department.', 400); + } + } +} diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php index eaab2c29..ccfdd489 100644 --- a/services/nginx/app/classes/economic_v2_distribution_service.php +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -40,6 +40,7 @@ class economic_v2_distribution_service public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null) { + department_customer_price_overrides_schema_bootstrap::ensureTables(); $this->versioning = $versioning ?? new economic_v2_versioning_service(); $this->economic = $economic; } @@ -388,7 +389,7 @@ class economic_v2_distribution_service continue; } - $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at); + $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id); if ($discount_row === null) { continue; } @@ -1528,7 +1529,7 @@ class economic_v2_distribution_service $line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity; } - $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp); + $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id); if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) { $line_price = ((float)$discount_row['fixed_price']) * $quantity; $total += $line_price; @@ -1544,14 +1545,18 @@ class economic_v2_distribution_service return $total; } - protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array + protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array { - $cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19); + $cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . substr($timestamp, 0, 19); if (array_key_exists($cache_key, $this->discount_resolution_cache)) { return $this->discount_resolution_cache[$cache_key]; } - $direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp); + $scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id) + ? (int)$department_id + : null; + + $direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId); if ($direct !== null && ( (array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null) || (int)($direct['discount'] ?? 0) > 0 @@ -1563,13 +1568,20 @@ class economic_v2_distribution_service if ($product !== null) { $category = (string)$product->category->value(); if ($category !== '') { - $category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp); + $category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId); if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) { return $this->discount_resolution_cache[$cache_key] = $category_discount; } } } + if ($scopedDepartmentId !== null) { + $global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId); + if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) { + return $this->discount_resolution_cache[$cache_key] = $global_discount; + } + } + return $this->discount_resolution_cache[$cache_key] = null; } diff --git a/services/nginx/app/classes/economic_v2_schema_bootstrap.php b/services/nginx/app/classes/economic_v2_schema_bootstrap.php index aaaf3427..4000ae91 100644 --- a/services/nginx/app/classes/economic_v2_schema_bootstrap.php +++ b/services/nginx/app/classes/economic_v2_schema_bootstrap.php @@ -60,6 +60,7 @@ class economic_v2_schema_bootstrap "CREATE TABLE IF NOT EXISTS customer_discount_override_versions ( id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NULL DEFAULT NULL, user_id INT NOT NULL, customer_number INT NOT NULL, is_category TINYINT(1) NOT NULL, @@ -75,6 +76,7 @@ class economic_v2_schema_bootstrap created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to), + INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to), INDEX idx_discount_override_versions_user (user_id), INDEX idx_discount_override_versions_source (source) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", @@ -92,6 +94,14 @@ class economic_v2_schema_bootstrap ); } + if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) { + $db->query( + "ALTER TABLE customer_discount_override_versions + ADD COLUMN department_id INT NULL DEFAULT NULL + AFTER id" + ); + } + self::$initialized = true; } diff --git a/services/nginx/app/classes/economic_v2_versioning_service.php b/services/nginx/app/classes/economic_v2_versioning_service.php index e6c18977..e96d7656 100644 --- a/services/nginx/app/classes/economic_v2_versioning_service.php +++ b/services/nginx/app/classes/economic_v2_versioning_service.php @@ -136,7 +136,8 @@ class economic_v2_versioning_service float $confidence = 1.0, bool $inferred = false, array $metadata = [], - ?int $fixed_price = null + ?int $fixed_price = null, + ?int $department_id = null ): array { $identity = [ 'user_id' => $user_id, @@ -144,6 +145,9 @@ class economic_v2_versioning_service 'is_category' => (int)$is_category, 'object_id' => (string)$object_id, ]; + if ($department_id !== null) { + $identity['department_id'] = (int)$department_id; + } if (($discount === null || (int)$discount === 0) && $fixed_price === null) { return $this->closeActiveVersion( @@ -240,15 +244,21 @@ class economic_v2_versioning_service int $customer_number, bool $is_category, int|string $object_id, - string $timestamp + string $timestamp, + ?int $department_id = null ): ?array { + $identity = [ + 'customer_number' => $customer_number, + 'is_category' => (int)$is_category, + 'object_id' => (string)$object_id, + ]; + if ($department_id !== null) { + $identity['department_id'] = (int)$department_id; + } + $rows = $this->resolveActiveVersions( 'customer_discount_override_versions', - [ - 'customer_number' => $customer_number, - 'is_category' => (int)$is_category, - 'object_id' => (string)$object_id, - ], + $identity, $timestamp, 'effective_from DESC, id DESC', 1 diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index 3c47c04d..56730164 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -31,6 +31,7 @@ class invoice_period_flag_service { invoice_period_flag_schema_bootstrap::ensureTables(); price_overrides_schema_bootstrap::ensureColumns(); + department_customer_price_overrides_schema_bootstrap::ensureTables(); } public function createManualFlag(array $payload, int $userId): array @@ -712,9 +713,21 @@ class invoice_period_flag_service p.max_quantity_per_order, c.name AS category_name, pdp.price AS department_price, - product_discount.percentage AS product_discount_percentage, - product_discount.fixed_price AS product_fixed_price, - category_discount.percentage AS category_discount_percentage + CASE + WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage + ELSE product_discount.percentage + END AS product_discount_percentage, + CASE + WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price + ELSE product_discount.fixed_price + END AS product_fixed_price, + CASE + WHEN d.custom_pricing_only = 1 THEN GREATEST( + COALESCE(department_category_discount.percentage, 0), + COALESCE(department_global_discount.percentage, 0) + ) + ELSE category_discount.percentage + END AS category_discount_percentage FROM orders o LEFT JOIN ( SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name @@ -745,6 +758,32 @@ class invoice_period_flag_service ) category_discount ON category_discount.customer_number = o.customer_id AND category_discount.product_or_category_id = p.category + LEFT JOIN ( + SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price + FROM department_customer_price_overrides + WHERE is_category = 0 + GROUP BY department_id, user_id, product_or_category_id + ) department_product_discount + ON department_product_discount.department_id = o.department_id + AND department_product_discount.user_id = u.id + AND department_product_discount.product_or_category_id = p.id + LEFT JOIN ( + SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage + FROM department_customer_price_overrides + WHERE is_category = 1 AND product_or_category_id <> 'global' + GROUP BY department_id, user_id, product_or_category_id + ) department_category_discount + ON department_category_discount.department_id = o.department_id + AND department_category_discount.user_id = u.id + AND department_category_discount.product_or_category_id = p.category + LEFT JOIN ( + SELECT department_id, user_id, MAX(percentage) AS percentage + FROM department_customer_price_overrides + WHERE is_category = 1 AND product_or_category_id = 'global' + GROUP BY department_id, user_id + ) department_global_discount + ON department_global_discount.department_id = o.department_id + AND department_global_discount.user_id = u.id WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}' AND o.deleted_at IS NULL ORDER BY o.customer_id, o.id, oi.id"; diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index ff154f1a..35c56cac 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -795,6 +795,34 @@ class limited_backoffice_service return $this->getDepartmentPrices($user, $departmentId); } + /** + * @return array + */ + public function getDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId): array + { + $this->assertDepartmentAccess($user, $departmentId); + return (new department_customer_pricing_service())->getPricing($departmentId, $customerUserId); + } + + /** + * @param array $payload + * @return array + */ + public function updateDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId, array $payload): array + { + $this->assertDepartmentAccess($user, $departmentId); + + if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) { + throw new limited_backoffice_exception('Department ID in body does not match the route.', 400); + } + + return (new department_customer_pricing_service())->updatePricing($departmentId, $customerUserId, [ + ...$payload, + 'department_id' => $departmentId, + 'user_id' => $customerUserId, + ]); + } + /** * @return array> */ diff --git a/services/nginx/app/objects/department_customer_price_overrides_o.php b/services/nginx/app/objects/department_customer_price_overrides_o.php new file mode 100644 index 00000000..49e82ff0 --- /dev/null +++ b/services/nginx/app/objects/department_customer_price_overrides_o.php @@ -0,0 +1,114 @@ +setTable('department_customer_price_overrides'); + } + + public function objectChanged(): void + { + } + + public function setPrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId, int $percentage, ?int $fixedPrice = null): void + { + global $db; + + if ($isCategory) { + $fixedPrice = null; + } + + $this->removePrice($departmentId, $userId, $isCategory, $objectId); + + if ($percentage <= 0 && $fixedPrice === null) { + return; + } + + $objectId = $db->escape_string((string)$objectId); + $fixedPriceSql = $fixedPrice === null ? 'NULL' : (string)max(0, (int)$fixedPrice); + $sql = "INSERT INTO {$this->table} (`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`) + VALUES (" . (int)$departmentId . ", " . (int)$userId . ", " . (int)$isCategory . ", '{$objectId}', " . (int)$percentage . ", {$fixedPriceSql})"; + $db->query($sql); + } + + public function removePrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId): void + { + global $db; + + $objectId = $db->escape_string((string)$objectId); + $sql = "DELETE FROM {$this->table} + WHERE `department_id` = " . (int)$departmentId . " + AND `user_id` = " . (int)$userId . " + AND `is_category` = " . (int)$isCategory . " + AND `product_or_category_id` = '{$objectId}'"; + $db->query($sql); + } + + /** + * @return array> + */ + public function getAllPrices(int $departmentId, int $userId): array + { + global $db; + + $sql = "SELECT * FROM {$this->table} + WHERE `department_id` = " . (int)$departmentId . " + AND `user_id` = " . (int)$userId . " + ORDER BY `is_category` DESC, `product_or_category_id` ASC"; + $result = $db->query($sql); + $prices = []; + if ($result && $result->num_rows > 0) { + while ($row = $result->fetch_assoc()) { + $prices[] = $this->parseRow($row); + } + } + + return $prices; + } + + public function getDirectPriceRow(int $departmentId, int $userId, bool $isCategory, int|string $objectId): ?array + { + global $db; + + $objectId = $db->escape_string((string)$objectId); + $sql = "SELECT * FROM {$this->table} + WHERE `department_id` = " . (int)$departmentId . " + AND `user_id` = " . (int)$userId . " + AND `is_category` = " . (int)$isCategory . " + AND `product_or_category_id` = '{$objectId}' + LIMIT 1"; + $result = $db->query($sql); + if (!$result || $result->num_rows < 1) { + return null; + } + + return $this->parseRow($result->fetch_assoc()); + } + + private function parseRow(array $row): array + { + $isCategory = (bool)$row['is_category']; + + return [ + 'id' => (int)$row['id'], + 'department_id' => (int)$row['department_id'], + 'user_id' => (int)$row['user_id'], + 'is_category' => $isCategory, + 'product_or_category_id' => $isCategory ? (string)$row['product_or_category_id'] : (int)$row['product_or_category_id'], + 'percentage' => (int)$row['percentage'], + 'fixed_price' => array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null, + 'created_at' => (string)$row['created_at'], + 'updated_at' => (string)$row['updated_at'], + ]; + } +} diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index ba4bffc6..fa2ee529 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -178,7 +178,7 @@ class order_items_o extends db // Check if the user has a discount on the product, or category $customer = (new orders_o())->getOrderCustomer($order_id); if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { - $price = $customer->applyProductCustomerPricing($product_id, (int)$price, false); + $price = $customer->applyProductCustomerPricing($product_id, (int)$price, false, (int)$order->department_id->value()); } // If the price is forced, set the price to the forced price diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 3de215b6..5a28ab8c 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1436,7 +1436,7 @@ class orders_o extends db throw new Exception('No user found matching the customer number in the usage log'); } if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { - $product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price); + $product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value()); } $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 @@ -1509,7 +1509,7 @@ class orders_o extends db if (products_o::priceResolutionIsCustomMissing($priceResolution)) { return $price; } - return $current_user->applyProductCustomerPricing((int)$product->id, $price); + return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value()); } /** @@ -1598,7 +1598,7 @@ class orders_o extends db } $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); + $unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id); } $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 83dc6e1a..d289c286 100644 --- a/services/nginx/app/objects/products_o.php +++ b/services/nginx/app/objects/products_o.php @@ -326,13 +326,13 @@ class products_o extends db return $this->getDepartmentPriceResolution($department_id)['price']; } - public function applyCustomerDiscounts(array $products, users_o $customer): array + public function applyCustomerDiscounts(array $products, users_o $customer, ?int $departmentId = null): array { global $db; // Get the customer's discounts - return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer), $products); + return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer, $departmentId), $products); } - public function applyCustomerDiscount(array $product, users_o $customer): array + public function applyCustomerDiscount(array $product, users_o $customer, ?int $departmentId = null): array { global $db; // Validate input @@ -340,7 +340,7 @@ class products_o extends db throw new \InvalidArgumentException('Invalid product array, must contain id and price keys'); } if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) { - $product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']); + $product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price'], true, $departmentId); } unset($product[self::PRICE_SOURCE_KEY]); return $product; diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index 121db195..1cbb2d9e 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -936,9 +936,18 @@ class users_o extends db * @param bool $is_category If the object is a category * @return int|null The discount percentage */ - public function getCustomPrice(int $object_id, bool $is_category = false): int|null + public function getCustomPrice(int $object_id, bool $is_category = false, ?int $department_id = null): int|null { self::requireSelected(); + if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) { + $row = (new department_customer_price_overrides_o())->getDirectPriceRow( + (int)$department_id, + (int)$this->id, + $is_category, + $object_id + ); + return $row === null ? 0 : (int)$row['percentage']; + } // Get the custom price for the product $discount = $this->price_overrides->setUser($this->id)->getPrice($is_category, $object_id); // If the is_category is false, check if there is a custom price for the category that the product belongs to @@ -962,9 +971,12 @@ class users_o extends db * - Category discount (If the product allows category inheritance of discounts) * - Product discount (If the product has a custom price) */ - public function getProductDiscountPercentage(int $product_id): int|null + public function getProductDiscountPercentage(int $product_id, ?int $department_id = null): int|null { self::requireSelected(); + if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) { + return $this->getDepartmentScopedProductDiscountPercentage($product_id, (int)$department_id); + } // Get the product by ID $product = (new products_o())->select((int)$product_id); $doesProductAllowCategoryDiscount = (bool)$product->apply_category_discount->value(); @@ -981,24 +993,36 @@ class users_o extends db return $discount_percentage === null ? 0 : (int)$discount_percentage; } - public function getProductFixedPrice(int $product_id): ?int + public function getProductFixedPrice(int $product_id, ?int $department_id = null): ?int { self::requireSelected(); + if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) { + $row = (new department_customer_price_overrides_o())->getDirectPriceRow( + (int)$department_id, + (int)$this->id, + false, + (int)$product_id + ); + if ($row === null || $row['fixed_price'] === null) { + return null; + } + return (int)$row['fixed_price']; + } return $this->price_overrides->setUser($this->id)->getFixedPrice(false, $product_id); } - public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true): int + public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true, ?int $department_id = null): int { self::requireSelected(); - $fixed_price = $this->getProductFixedPrice($product_id); + $fixed_price = $this->getProductFixedPrice($product_id, $department_id); if ($fixed_price !== null) { return $fixed_price; } $discount_percentage = $use_final_price_discount_calculation - ? (int)$this->getProductDiscountPercentage($product_id) - : (int)$this->getCustomPrice($product_id, false); + ? (int)$this->getProductDiscountPercentage($product_id, $department_id) + : (int)$this->getCustomPrice($product_id, false, $department_id); if ($discount_percentage <= 0) { return $base_price; } @@ -1006,6 +1030,34 @@ class users_o extends db return (int)round($base_price * (1 - ($discount_percentage / 100))); } + private function getDepartmentScopedProductDiscountPercentage(int $product_id, int $department_id): int + { + self::requireSelected(); + + $overrides = new department_customer_price_overrides_o(); + $product = (new products_o())->select((int)$product_id); + $discounts = []; + + $productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id); + if ($productRow !== null) { + $discounts[] = (int)$productRow['percentage']; + } + + if ((bool)$product->apply_category_discount->value()) { + $categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value()); + if ($categoryRow !== null) { + $discounts[] = (int)$categoryRow['percentage']; + } + + $globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global'); + if ($globalRow !== null) { + $discounts[] = (int)$globalRow['percentage']; + } + } + + return max([0, ...$discounts]); + } + /** diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 646c6b6b..a43abc1d 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -12667,6 +12667,139 @@ paths: application/json: schema: {} + /superuser/department/customer-pricing: + get: + tags: + - Departments + summary: Get department-specific customer pricing + description: Returns customer price overrides and the department product catalog when custom-only pricing is enabled for the department. + operationId: getSuperuserDepartmentCustomerPricing + parameters: + - name: department_id + in: query + required: true + schema: + type: integer + minimum: 1 + - name: user_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department customer pricing + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + put: + tags: + - Departments + summary: Replace department-specific customer pricing + description: Replaces the complete override set for one customer in one custom-only department. + operationId: setSuperuserDepartmentCustomerPricing + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingUpdateRequest' + responses: + '200': + description: Department customer pricing updated + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + + /limited-backoffice/departments/{departmentId}/customer-pricing: + get: + tags: + - Limited Backoffice + summary: Get limited-backoffice department customer pricing + description: Returns department-specific customer pricing for an assigned custom-only department. + operationId: getLimitedBackofficeDepartmentCustomerPricing + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + - name: user_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department customer pricing + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + put: + tags: + - Limited Backoffice + summary: Replace limited-backoffice department customer pricing + description: Replaces the complete override set for one customer in an assigned custom-only department. + operationId: setLimitedBackofficeDepartmentCustomerPricing + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingLimitedUpdateRequest' + responses: + '200': + description: Department customer pricing updated + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCustomerPricingResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + /superuser/department/variables: get: tags: @@ -13482,6 +13615,164 @@ components: type: integer description: HTTP status code + DepartmentCustomerPricingUpdateRequest: + type: object + required: + - department_id + - user_id + - overrides + properties: + department_id: + type: integer + minimum: 1 + user_id: + type: integer + minimum: 1 + overrides: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingOverrideInput' + + DepartmentCustomerPricingLimitedUpdateRequest: + type: object + required: + - user_id + - overrides + properties: + user_id: + type: integer + minimum: 1 + overrides: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingOverrideInput' + + DepartmentCustomerPricingOverrideInput: + type: object + required: + - is_category + - product_or_category_id + properties: + is_category: + type: boolean + product_or_category_id: + oneOf: + - type: integer + - type: string + description: Product id, category id, or `global` for the customer-wide department discount. + discount: + type: integer + minimum: 0 + maximum: 100 + percentage: + type: integer + minimum: 0 + maximum: 100 + fixed_price: + type: integer + nullable: true + minimum: 0 + + DepartmentCustomerPricingOverride: + allOf: + - $ref: '#/components/schemas/DepartmentCustomerPricingOverrideInput' + - type: object + properties: + id: + type: integer + department_id: + type: integer + user_id: + type: integer + percentage: + type: integer + minimum: 0 + maximum: 100 + created_at: + type: string + updated_at: + type: string + + DepartmentCustomerPricingProduct: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + category: + type: integer + apply_category_discount: + type: boolean + base_price: + type: integer + department_price: + type: integer + nullable: true + effective_price: + type: integer + missing_department_price: + type: boolean + + DepartmentCustomerPricingCategory: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + products: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingProduct' + + DepartmentCustomerPricingResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + department: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + custom_pricing_only: + type: boolean + customer: + type: object + properties: + id: + type: integer + customer_number: + type: integer + display_name: + type: string + overrides: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingOverride' + categories: + type: array + items: + $ref: '#/components/schemas/DepartmentCustomerPricingCategory' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + ErrorReportSubmissionRequest: type: object required: diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index a546c02c..39d5eed0 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -1735,7 +1735,7 @@ class InvoicingPeriodRoute if (!array_key_exists($product_id, $discount_cache)) { $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); + $unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false, $department_id); } $discount_cache[$product_id] = $unit_price; } diff --git a/services/nginx/app/routes/limitedBackofficeRoute.php b/services/nginx/app/routes/limitedBackofficeRoute.php index 30327f34..a4d2c9d6 100644 --- a/services/nginx/app/routes/limitedBackofficeRoute.php +++ b/services/nginx/app/routes/limitedBackofficeRoute.php @@ -44,6 +44,31 @@ class limitedBackofficeRoute limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices', ]); + $this->get('/limited-backoffice/departments/{departmentId}/customer-pricing', function () { + $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { + $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); + $departmentId = $this->routePositiveInt('departmentId'); + $customerUserId = $this->queryCustomerUserId(); + return $service->getDepartmentCustomerPricing($user, $departmentId, $customerUserId); + }); + }, [ + limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', + ]); + + $this->put('/limited-backoffice/departments/{departmentId}/customer-pricing', function () { + $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { + $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); + $this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_PRICES); + $departmentId = $this->routePositiveInt('departmentId'); + $payload = $this->requestPayload(); + $customerUserId = $this->payloadCustomerUserId($payload); + return $service->updateDepartmentCustomerPricing($user, $departmentId, $customerUserId, $payload); + }); + }, [ + limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', + limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices', + ]); + $this->get('/limited-backoffice/roles', function () { $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); @@ -154,4 +179,59 @@ class limitedBackofficeRoute } return (int)$value; } + + private function queryPositiveInt(string $name): int + { + $value = $this->fromQuery($name); + if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) { + throw new limited_backoffice_exception('Invalid query parameter.', 400); + } + return (int)$value; + } + + private function queryCustomerUserId(): int + { + if ($this->fromQuery('user_id') !== null) { + return $this->queryPositiveInt('user_id'); + } + + return $this->userIdFromCustomerNumber($this->queryPositiveInt('customer_number')); + } + + /** + * @param array $payload + */ + private function payloadPositiveInt(array $payload, string $name): int + { + $value = $payload[$name] ?? null; + if (is_int($value) && $value > 0) { + return $value; + } + if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) { + throw new limited_backoffice_exception('Invalid request parameter.', 400); + } + return (int)$value; + } + + /** + * @param array $payload + */ + private function payloadCustomerUserId(array $payload): int + { + if (array_key_exists('user_id', $payload)) { + return $this->payloadPositiveInt($payload, 'user_id'); + } + + return $this->userIdFromCustomerNumber($this->payloadPositiveInt($payload, 'customer_number')); + } + + private function userIdFromCustomerNumber(int $customerNumber): int + { + $customer = (new \objects\users_o())->getUserByCustomerNumber($customerNumber); + if (!$customer->exists()) { + throw new limited_backoffice_exception('Customer not found', 404); + } + + return (int)$customer->id; + } } diff --git a/services/nginx/app/routes/productsRoute.php b/services/nginx/app/routes/productsRoute.php index 1965641a..3ce51167 100644 --- a/services/nginx/app/routes/productsRoute.php +++ b/services/nginx/app/routes/productsRoute.php @@ -158,7 +158,7 @@ class productsRoute // Check if the customer is set if ($customer !== null) { // Apply the customers unique discounts - $products = (new products_o())->applyCustomerDiscounts($products, $customer); + $products = (new products_o())->applyCustomerDiscounts($products, $customer, $departmentId); } else { $products = products_o::stripDepartmentPriceSources($products); } diff --git a/services/nginx/app/routes/superuserDepartmentRoute.php b/services/nginx/app/routes/superuserDepartmentRoute.php index ff76ddc9..27553940 100644 --- a/services/nginx/app/routes/superuserDepartmentRoute.php +++ b/services/nginx/app/routes/superuserDepartmentRoute.php @@ -3,11 +3,14 @@ namespace routes; use classes\authentication; +use classes\department_customer_pricing_service; +use classes\limited_backoffice_exception; use objects\branding_o; use objects\department_variables_o; use objects\departments_o; use objects\logs_o; use objects\products_o; +use objects\users_o; use traits\route_t; class superuserDepartmentRoute @@ -213,6 +216,57 @@ class superuserDepartmentRoute 'superuser_fetch_department_prices' => 'Fetch department prices' ]); + $this->get('/superuser/department/customer-pricing', function () { + global $response; + $this->requirePermission('superuser_fetch_department_customer_pricing'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $departmentId = $this->positiveIntFromRequest('department_id'); + $userId = $this->customerUserIdFromRequest(); + + try { + (new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'Successfully fetched department customer pricing'); + $response->success((new department_customer_pricing_service())->getPricing($departmentId, $userId)); + } catch (limited_backoffice_exception $exception) { + $response->error($exception->payload(), $exception->statusCode()); + } + }, [ + 'superuser_fetch_department_customer_pricing' => 'Fetch department customer pricing' + ]); + + $this->put('/superuser/department/customer-pricing', function () { + global $response; + $this->requirePermission('superuser_set_department_customer_pricing'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $payload = json_decode(file_get_contents('php://input'), true); + if (!is_array($payload)) { + $response->error('Invalid request body', 400); + } + + $departmentId = $this->positiveIntFromPayload($payload, 'department_id'); + $userId = $this->customerUserIdFromPayload($payload); + + try { + (new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'Successfully set department customer pricing'); + $response->success((new department_customer_pricing_service())->updatePricing($departmentId, $userId, $payload)); + } catch (limited_backoffice_exception $exception) { + $response->error($exception->payload(), $exception->statusCode()); + } + }, [ + 'superuser_set_department_customer_pricing' => 'Set department customer pricing' + ]); + $this->get('/superuser/department/variables', function () { // Require the user to be logged in global $response; @@ -295,4 +349,78 @@ class superuserDepartmentRoute ]); } + + private function positiveIntFromRequest(string $name): int + { + global $response; + $value = $this->fromRequest($name); + if (!is_string($value) && !is_int($value)) { + $response->error($name . ' is required', 400); + } + + return $this->positiveIntValue($value, $name); + } + + private function customerUserIdFromRequest(): int + { + if ($this->fromRequest('user_id') !== null) { + return $this->positiveIntFromRequest('user_id'); + } + + return $this->userIdFromCustomerNumber($this->positiveIntFromRequest('customer_number')); + } + + /** + * @param array $payload + */ + private function positiveIntFromPayload(array $payload, string $name): int + { + global $response; + if (!array_key_exists($name, $payload)) { + $response->error($name . ' is required', 400); + } + + return $this->positiveIntValue($payload[$name], $name); + } + + /** + * @param array $payload + */ + private function customerUserIdFromPayload(array $payload): int + { + if (array_key_exists('user_id', $payload)) { + return $this->positiveIntFromPayload($payload, 'user_id'); + } + + return $this->userIdFromCustomerNumber($this->positiveIntFromPayload($payload, 'customer_number')); + } + + private function positiveIntValue(mixed $value, string $name): int + { + global $response; + if (is_int($value)) { + $parsed = $value; + } elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) { + $parsed = (int)trim($value); + } else { + $response->error($name . ' must be a positive integer', 400); + } + + if ($parsed <= 0) { + $response->error($name . ' must be a positive integer', 400); + } + + return $parsed; + } + + private function userIdFromCustomerNumber(int $customerNumber): int + { + global $response; + $customer = (new users_o())->getUserByCustomerNumber($customerNumber); + if (!$customer->exists()) { + $response->error('Customer not found', 404); + } + + return (int)$customer->id; + } } diff --git a/services/nginx/app/tests/Api/DepartmentCustomerPricingApiTest.php b/services/nginx/app/tests/Api/DepartmentCustomerPricingApiTest.php new file mode 100644 index 00000000..e541ebcf --- /dev/null +++ b/services/nginx/app/tests/Api/DepartmentCustomerPricingApiTest.php @@ -0,0 +1,184 @@ +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, + ]); +} + +function department_customer_pricing_setup(array $departmentAttributes = []): array +{ + $department = api_fixtures()->createDepartment([ + 'name' => 'Scoped Customer Pricing Department', + 'custom_pricing_only' => 1, + ...$departmentAttributes, + ]); + $category = api_fixtures()->createCategory(['name' => 'Scoped Customer Pricing Category']); + $product = api_fixtures()->createProduct([ + 'name' => 'Scoped Customer Pricing Product', + 'category' => $category['id'], + 'price' => 1000, + 'apply_category_discount' => 1, + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + department_customer_pricing_price_insert((int)$department['id'], (int)$product['id'], 1000); + $customer = api_fixtures()->createUser(['display_name' => 'Scoped Customer Pricing Customer']); + + return [ + 'department' => $department, + 'category' => $category, + 'product' => $product, + 'customer' => $customer, + ]; +} + +it('rejects department customer pricing when custom-only pricing is disabled', function (): void { + api_test_covers('GET /superuser/department/customer-pricing', 'validation'); + + $fixture = department_customer_pricing_setup(['custom_pricing_only' => 0]); + $session = api_fixtures()->createUserSession(['superuser_fetch_department_customer_pricing']); + + $response = api_client()->get( + '/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] . + '&user_id=' . (int)$fixture['customer']['id'], + $session['headers'] + ); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false); + + expect($response->data()['code'] ?? null)->toBe('department_customer_pricing_disabled'); +}); + +it('sets and applies department-specific customer discounts without legacy fallback', function (): void { + api_test_covers('GET /superuser/department/customer-pricing', 'happy'); + api_test_covers('PUT /superuser/department/customer-pricing', 'happy'); + api_test_covers('GET /products', 'pricing'); + + $fixture = department_customer_pricing_setup(); + api_fixtures()->createPriceOverride([ + 'user_id' => $fixture['customer']['id'], + 'is_category' => 0, + 'product_or_category_id' => (string)$fixture['product']['id'], + 'percentage' => 80, + ]); + + $session = api_fixtures()->createUserSession([ + 'superuser_fetch_department_customer_pricing', + 'superuser_set_department_customer_pricing', + 'list_products', + 'department_access_' . (int)$fixture['department']['id'], + ]); + + $updated = api_client()->put('/superuser/department/customer-pricing', [ + 'department_id' => $fixture['department']['id'], + 'user_id' => $fixture['customer']['id'], + 'overrides' => [ + [ + 'is_category' => false, + 'product_or_category_id' => $fixture['product']['id'], + 'discount' => 25, + 'fixed_price' => null, + ], + ], + ], $session['headers']); + + $updated + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(25); + expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(750); + + $byCustomerNumber = api_client()->get( + '/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] . + '&customer_number=' . (int)$fixture['customer']['customer_number'], + $session['headers'] + ); + $byCustomerNumber + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect($byCustomerNumber->data()['customer']['id'] ?? null)->toBe((int)$fixture['customer']['id']); + + $productResponse = api_client()->get( + '/products?final_price=true&id=' . (int)$fixture['product']['id'] . + '&department_id=' . (int)$fixture['department']['id'] . + '&customer_id=' . (int)$fixture['customer']['customer_number'], + $session['headers'] + ); + + $productResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($productResponse->data()['price'] ?? 0))->toBe(750); +}); + +it('limits department customer pricing to assigned limited-backoffice departments', function (): void { + api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth'); + api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth'); + + $fixture = department_customer_pricing_setup(); + $otherFixture = department_customer_pricing_setup(['name' => 'Denied Scoped Customer Pricing Department']); + $session = api_fixtures()->createUserSession([ + limited_backoffice_service::PERMISSION_ACCESS, + limited_backoffice_service::PERMISSION_MANAGE_PRICES, + 'department_access_' . (int)$fixture['department']['id'], + ]); + + api_client() + ->get( + '/limited-backoffice/departments/' . (int)$otherFixture['department']['id'] . + '/customer-pricing?user_id=' . (int)$otherFixture['customer']['id'], + $session['headers'] + ) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . (int)$otherFixture['department']['id']]); + + $updated = api_client()->put( + '/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing', + [ + 'user_id' => $fixture['customer']['id'], + 'overrides' => [ + [ + 'is_category' => true, + 'product_or_category_id' => (string)$fixture['category']['id'], + 'discount' => 30, + ], + ], + ], + $session['headers'] + ); + + $updated + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(30); + expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(700); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index ebe57a80..1fd35eee 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -1837,6 +1837,7 @@ final class ApiFixtures $this->deleteWhereIfPossible('tokens', ['user_id' => $userId]); $this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]); $this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]); + $this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]); $this->deleteWhereIfPossible('limited_backoffice_employees', ['user_id' => $userId]); $this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]); $this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]); diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 6588efb6..1d4715ee 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -810,6 +810,23 @@ CREATE TABLE IF NOT EXISTS `price_overrides` ( KEY `idx_price_overrides_user_id` (`user_id`), KEY `idx_price_overrides_lookup` (`user_id`, `is_category`, `product_or_category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_customer_price_overrides' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `user_id` INT NOT NULL, + `is_category` TINYINT(1) NOT NULL DEFAULT 0, + `product_or_category_id` VARCHAR(191) NOT NULL, + `percentage` INT NOT NULL DEFAULT 0, + `fixed_price` INT NULL DEFAULT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`), + KEY `idx_department_customer_price_overrides_department` (`department_id`), + KEY `idx_department_customer_price_overrides_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'products_options' => <<<'SQL' CREATE TABLE IF NOT EXISTS `products_options` (