*/ 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']), 'revision' => $this->revision($overrides), ]; } /** * @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(); $expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null); $existingOverrides = []; $normalizedKeys = []; foreach ($normalized as $override) { $normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true; } global $db; $mysqli = $db->conn(); $mysqli->begin_transaction(); try { $this->lockDepartment($departmentId); $existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']); $currentRevision = $this->revision($existingOverrides); if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) { throw $this->revisionConflict($currentRevision); } $deleteStatement = $mysqli->prepare( 'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ? AND `user_id` = ?' ); $insertStatement = $mysqli->prepare( 'INSERT INTO `department_customer_price_overrides` (`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`) VALUES (?, ?, ?, ?, ?, ?)' ); if ($deleteStatement === false || $insertStatement === false) { throw new \RuntimeException('Unable to prepare department customer pricing update.'); } $customerId = (int)$customer['id']; $deleteStatement->bind_param('ii', $departmentId, $customerId); if (!$deleteStatement->execute()) { throw new \RuntimeException('Unable to clear department customer pricing.'); } foreach ($normalized as $override) { $isCategory = (int)(bool)$override['is_category']; $objectId = (string)$override['product_or_category_id']; $percentage = (int)$override['percentage']; $fixedPrice = $override['fixed_price'] === null ? null : (int)$override['fixed_price']; $insertStatement->bind_param( 'iiisii', $departmentId, $customerId, $isCategory, $objectId, $percentage, $fixedPrice ); if (!$insertStatement->execute()) { throw new \RuntimeException('Unable to save department customer pricing.'); } } $deleteStatement->close(); $insertStatement->close(); $mysqli->commit(); } catch (limited_backoffice_exception $exception) { $mysqli->rollback(); throw $exception; } catch (\Throwable) { $mysqli->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) { if ($fixedPrice !== null) { throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400); } $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); if (isset($normalized[$key])) { throw new limited_backoffice_exception('Duplicate customer price overrides are not allowed.', 400); } if ($fixedPrice !== null && $percentage > 0) { throw new limited_backoffice_exception('Choose either a discount or a fixed price.', 400); } $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; } /** * @param array> $overrides */ private function revision(array $overrides): string { $revisionRows = array_map(static fn(array $override): array => [ 'is_category' => (bool)$override['is_category'], 'product_or_category_id' => (string)$override['product_or_category_id'], 'percentage' => (int)$override['percentage'], 'fixed_price' => $override['fixed_price'] === null ? null : (int)$override['fixed_price'], ], $overrides); usort($revisionRows, static function (array $left, array $right): int { return [$left['is_category'] ? 0 : 1, $left['product_or_category_id']] <=> [$right['is_category'] ? 0 : 1, $right['product_or_category_id']]; }); return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR)); } private function normalizeExpectedRevision(mixed $value): ?string { if ($value === null || $value === '') { // Keep the backend-first rollout compatible with the currently deployed UI. return null; } if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) { throw new limited_backoffice_exception('Expected revision is invalid.', 400, [ 'message' => 'Expected revision is invalid.', 'code' => 'pricing_revision_invalid', ]); } return $value; } private function lockDepartment(int $departmentId): void { global $db; $result = $db->query( 'SELECT `id` FROM `departments` WHERE `id` = ' . (int)$departmentId . ' FOR UPDATE' ); if (!$result || $result->num_rows < 1) { throw new limited_backoffice_exception('Department not found', 404); } } private function revisionConflict(string $currentRevision): limited_backoffice_exception { return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [ 'message' => 'Pricing has changed. Reload and try again.', 'code' => 'pricing_revision_conflict', 'current_revision' => $currentRevision, ]); } 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); } } }