From f02dfd8c9c5c90587f7518c51ea93c0b02a69a24 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 12:52:33 +0200 Subject: [PATCH] Add customer product fixed price overrides --- .../economic_v2_distribution_service.php | 20 ++- .../classes/economic_v2_schema_bootstrap.php | 10 +- .../economic_v2_versioning_service.php | 13 +- .../classes/invoice_period_flag_service.php | 22 ++- .../price_overrides_schema_bootstrap.php | 68 +++++++ .../classes/system_search_document_index.php | 4 + .../app/classes/system_search_service.php | 6 +- services/nginx/app/objects/order_items_o.php | 9 +- services/nginx/app/objects/orders_o.php | 12 +- services/nginx/app/objects/products_o.php | 7 +- .../app/objects/user_price_overrides_o.php | 88 +++++++-- services/nginx/app/objects/users_o.php | 30 +++- services/nginx/app/openapi.yaml | 9 +- .../nginx/app/routes/InvoicingPeriodRoute.php | 4 +- services/nginx/app/routes/orderItemsRoute.php | 28 ++- services/nginx/app/routes/userRoute.php | 32 +++- .../app/tests/Api/ApiFixturesCleanupTest.php | 1 + .../nginx/app/tests/Api/OrderItemsApiTest.php | 65 ++++++- .../app/tests/Api/UserDiscountsApiTest.php | 119 +++++++++++++ .../app/tests/Support/Api/ApiFixtures.php | 44 +++++ .../tests/Support/Api/ApiSchemaBootstrap.php | 12 ++ ...stributionServiceProductFixedPriceTest.php | 167 ++++++++++++++++++ .../InvoicePeriodFlagServiceTest.php | 27 +++ 23 files changed, 738 insertions(+), 59 deletions(-) create mode 100644 services/nginx/app/classes/price_overrides_schema_bootstrap.php create mode 100644 services/nginx/app/tests/Api/UserDiscountsApiTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceProductFixedPriceTest.php diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php index 162d51fa..eaab2c29 100644 --- a/services/nginx/app/classes/economic_v2_distribution_service.php +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -389,6 +389,15 @@ class economic_v2_distribution_service } $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at); + if ($discount_row === null) { + continue; + } + if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) { + $fixed_price = (float)$discount_row['fixed_price']; + $order_discount_total += (($base_price - $fixed_price) * $quantity); + continue; + } + $discount_percentage = (float)($discount_row['discount'] ?? 0); if ($discount_percentage <= 0) { continue; @@ -1520,6 +1529,12 @@ class economic_v2_distribution_service } $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp); + 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; + continue; + } + $discount_percentage = (float)($discount_row['discount'] ?? 0); if ($discount_percentage > 0) { $line_price *= (1 - ($discount_percentage / 100)); @@ -1537,7 +1552,10 @@ class economic_v2_distribution_service } $direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp); - if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) { + if ($direct !== null && ( + (array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null) + || (int)($direct['discount'] ?? 0) > 0 + )) { return $this->discount_resolution_cache[$cache_key] = $direct; } diff --git a/services/nginx/app/classes/economic_v2_schema_bootstrap.php b/services/nginx/app/classes/economic_v2_schema_bootstrap.php index 655ebf7d..aaaf3427 100644 --- a/services/nginx/app/classes/economic_v2_schema_bootstrap.php +++ b/services/nginx/app/classes/economic_v2_schema_bootstrap.php @@ -65,6 +65,7 @@ class economic_v2_schema_bootstrap is_category TINYINT(1) NOT NULL, object_id VARCHAR(64) NOT NULL, discount INT NOT NULL, + fixed_price INT NULL DEFAULT NULL, effective_from DATETIME NOT NULL, effective_to DATETIME NULL, source VARCHAR(64) NOT NULL DEFAULT 'live', @@ -83,6 +84,14 @@ class economic_v2_schema_bootstrap $db->query($sql); } + if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) { + $db->query( + "ALTER TABLE customer_discount_override_versions + ADD COLUMN fixed_price INT NULL DEFAULT NULL + AFTER discount" + ); + } + self::$initialized = true; } @@ -106,4 +115,3 @@ class economic_v2_schema_bootstrap return ((int)($row['c'] ?? 0)) > 0; } } - diff --git a/services/nginx/app/classes/economic_v2_versioning_service.php b/services/nginx/app/classes/economic_v2_versioning_service.php index 13c9ab50..e6c18977 100644 --- a/services/nginx/app/classes/economic_v2_versioning_service.php +++ b/services/nginx/app/classes/economic_v2_versioning_service.php @@ -135,7 +135,8 @@ class economic_v2_versioning_service string $source = 'live.discount_override', float $confidence = 1.0, bool $inferred = false, - array $metadata = [] + array $metadata = [], + ?int $fixed_price = null ): array { $identity = [ 'user_id' => $user_id, @@ -144,7 +145,7 @@ class economic_v2_versioning_service 'object_id' => (string)$object_id, ]; - if ($discount === null || (int)$discount === 0) { + if (($discount === null || (int)$discount === 0) && $fixed_price === null) { return $this->closeActiveVersion( 'customer_discount_override_versions', $identity, @@ -161,6 +162,7 @@ class economic_v2_versioning_service $identity, [ 'discount' => (int)$discount, + 'fixed_price' => $is_category ? null : $fixed_price, ], $this->normalizeDatetime($effective_from), $source, @@ -411,8 +413,11 @@ class economic_v2_versioning_service } // Discount overrides current state. + price_overrides_schema_bootstrap::ensureColumns(); $has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at'); + $has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price'); $discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' . + ($has_override_fixed_price ? ', po.fixed_price' : '') . ($has_override_created_at ? ', po.created_at' : ''); $discount_rows = $this->fetchAll( "SELECT $discount_cols @@ -434,7 +439,8 @@ class economic_v2_versioning_service 'backfill.current_discount_override', $confidence, true, - ['table' => 'price_overrides'] + ['table' => 'price_overrides'], + $has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null ); $this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop'); } @@ -707,4 +713,3 @@ class economic_v2_versioning_service $bucket[$action]++; } } - diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index bb8e0a56..ca71d207 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -30,6 +30,7 @@ class invoice_period_flag_service public function __construct() { invoice_period_flag_schema_bootstrap::ensureTables(); + price_overrides_schema_bootstrap::ensureColumns(); } public function createManualFlag(array $payload, int $userId): array @@ -711,6 +712,7 @@ class invoice_period_flag_service 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 FROM orders o LEFT JOIN ( @@ -724,7 +726,7 @@ class invoice_period_flag_service 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 LEFT JOIN ( - SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage + SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price FROM price_overrides po INNER JOIN users discount_user ON discount_user.id = po.user_id WHERE po.is_category = 0 @@ -1921,6 +1923,11 @@ class invoice_period_flag_service private function calculateExpectedPrice(array $row): int { + $fixedPrice = $this->rowProductFixedPrice($row); + if ($fixedPrice !== null) { + return $fixedPrice; + } + $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,6 +1943,7 @@ class invoice_period_flag_service 'product_price' => (int)($row['product_base_price'] ?? 0), 'department_price' => $departmentPrice, 'effective_base_price' => $base, + 'product_fixed_price' => $this->rowProductFixedPrice($row), 'product_discount_percentage' => $discount['product_discount_percentage'], 'category_discount_percentage' => $discount['category_discount_percentage'], 'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'], @@ -1950,15 +1958,25 @@ class invoice_period_flag_service $categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1; $categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0; $economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0; + $appliedDiscount = $this->rowProductFixedPrice($row) !== null + ? 0 + : max($productDiscount, $categoryDiscount, $economicDiscount); return [ 'product_discount_percentage' => $productDiscount, 'category_discount_percentage' => $categoryDiscount, 'economic_customer_discount_percentage' => $economicDiscount, - 'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount), + 'applied_discount_percentage' => $appliedDiscount, ]; } + private function rowProductFixedPrice(array $row): ?int + { + return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null + ? (int)$row['product_fixed_price'] + : null; + } + private function economicCustomerDiscountPercentage(array $row): int { $customerNumber = (int)($row['customer_number'] ?? 0); diff --git a/services/nginx/app/classes/price_overrides_schema_bootstrap.php b/services/nginx/app/classes/price_overrides_schema_bootstrap.php new file mode 100644 index 00000000..137562e3 --- /dev/null +++ b/services/nginx/app/classes/price_overrides_schema_bootstrap.php @@ -0,0 +1,68 @@ +query( + "ALTER TABLE price_overrides + ADD COLUMN fixed_price INT NULL DEFAULT NULL + AFTER percentage" + ); + } + + self::$initialized = true; + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/classes/system_search_document_index.php b/services/nginx/app/classes/system_search_document_index.php index b3844d77..4aa05d68 100644 --- a/services/nginx/app/classes/system_search_document_index.php +++ b/services/nginx/app/classes/system_search_document_index.php @@ -499,6 +499,7 @@ class system_search_document_index */ private function buildCustomerDiscountDocuments(): array { + price_overrides_schema_bootstrap::ensureColumns(); $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; $selectFields = [ 'po.id AS entity_id', @@ -506,6 +507,7 @@ class system_search_document_index 'po.is_category', 'po.product_or_category_id', 'po.percentage', + 'po.fixed_price', 'u.customer_number', 'u.display_name', ...$this->joinTemporalSelectFields('price_overrides', 'po'), @@ -554,6 +556,7 @@ class system_search_document_index $row['search_text'] ?? null, $row['product_or_category_id'] ?? null, $row['percentage'] ?? null, + $row['fixed_price'] ?? null, $row['user_id'] ?? null, ]), $this->toIntOrNull($row['customer_number'] ?? null), @@ -564,6 +567,7 @@ class system_search_document_index 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), 'product_or_category_id' => $row['product_or_category_id'] ?? null, 'percentage' => $this->toIntOrNull($row['percentage'] ?? null), + 'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null), 'economic_name' => $row['economic_name'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null, 'is_category' => $row['is_category'] ?? null, diff --git a/services/nginx/app/classes/system_search_service.php b/services/nginx/app/classes/system_search_service.php index 06b7dc21..7df08cb4 100644 --- a/services/nginx/app/classes/system_search_service.php +++ b/services/nginx/app/classes/system_search_service.php @@ -1086,6 +1086,7 @@ class system_search_service private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array { + price_overrides_schema_bootstrap::ensureColumns(); $customerFilter = ''; if (!empty($forcedCustomerNumbers)) { $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; @@ -1100,11 +1101,12 @@ class system_search_service 'po.is_category', 'po.product_or_category_id', 'po.percentage', + 'po.fixed_price', 'u.customer_number', 'u.display_name', ...$this->joinTemporalSelectFields('price_overrides', 'po'), ]; - $searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name']; + $searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name']; if ($this->isEconomicCustomerIndexAvailable()) { $fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; @@ -1166,6 +1168,7 @@ class system_search_service 'search_text', 'product_or_category_id', 'percentage', + 'fixed_price', 'user_id', ], $terms) + $entityBoost, 'payload' => $this->augmentPayloadWithTemporal([ @@ -1173,6 +1176,7 @@ class system_search_service 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'product_or_category_id' => $row['product_or_category_id'] ?? null, 'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null, + 'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null, 'economic_name' => $row['economic_name'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null, ], $row), diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index 38a43603..e8746659 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -172,13 +172,10 @@ 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); - $discount = $customer->getCustomPrice($product_id, false); - if ($discount) { - $price = $price - ($price * $discount / 100); - } + $price = $customer->applyProductCustomerPricing($product_id, (int)$price, false); // If the price is forced, set the price to the forced price - if ($forcePrice) { + if ($forcePrice !== null) { $price = (int)$forcePrice; } @@ -354,4 +351,4 @@ class order_items_o extends db { return (new products_o())->select((int)$this->product_id->value()); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 878d2110..fa4a1598 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1434,9 +1434,7 @@ class orders_o extends db if (!$user->exists()) { throw new Exception('No user found matching the customer number in the usage log'); } - $product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product - // Apply the discount percentage to the product price - $product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price + $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 @@ -1504,10 +1502,7 @@ class orders_o extends db throw new Exception('No current user found'); } $price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product - $discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product - // Apply the discount percentage to the product price - // Apply the discount percentage to the product price - return (int)round($price * (1 - ($discount_percentage / 100))); + return $current_user->applyProductCustomerPricing((int)$product->id, $price); } /** @@ -1594,8 +1589,7 @@ class orders_o extends db if ($tmp_user === null) { $tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); } - $discount = $tmp_user->getCustomPrice($product_id, false); - $post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity; + $post_discount = $tmp_user->applyProductCustomerPricing($product_id, (int)$department_price_cache[$product_id], false) * $quantity; $total += $post_discount; } diff --git a/services/nginx/app/objects/products_o.php b/services/nginx/app/objects/products_o.php index eea9c541..149b31a9 100644 --- a/services/nginx/app/objects/products_o.php +++ b/services/nginx/app/objects/products_o.php @@ -300,12 +300,7 @@ class products_o extends db if (!isset($product['id']) || !isset($product['price'])) { throw new \InvalidArgumentException('Invalid product array, must contain id and price keys'); } - // Get the customer's discount percentage - $discount_percentage = $customer->getProductDiscountPercentage($product['id']); - // Apply the discount to the product price - if ($discount_percentage > 0) { - $product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100)))); - } + $product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']); return $product; } diff --git a/services/nginx/app/objects/user_price_overrides_o.php b/services/nginx/app/objects/user_price_overrides_o.php index 71803b2d..999a9c11 100644 --- a/services/nginx/app/objects/user_price_overrides_o.php +++ b/services/nginx/app/objects/user_price_overrides_o.php @@ -4,6 +4,8 @@ namespace objects; use classes\db; use classes\object_property; +use classes\price_overrides_schema_bootstrap; +use classes\system_search_cache; use traits\db_object_t; class user_price_overrides_o extends db @@ -14,10 +16,12 @@ class user_price_overrides_o extends db public object_property $is_category; public object_property $product_or_category_id; public object_property $percentage; + public object_property $fixed_price; public function structure(): void { $this->setTable('price_overrides'); + price_overrides_schema_bootstrap::ensureColumns(); } public function objectChanged(): void @@ -30,6 +34,7 @@ class user_price_overrides_o extends db $this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true); $this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true); $this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true); + $this->fixed_price = new object_property($this->table, $this->id, 'fixed_price', 'int', false, null); } public function setUser($user_id): user_price_overrides_o @@ -43,39 +48,41 @@ class user_price_overrides_o extends db * @param bool $is_category * @param int|string $product_or_category_id * @param int $percentage + * @param int|null $fixed_price * @return $this */ - public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage): user_price_overrides_o + public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage, ?int $fixed_price = null): user_price_overrides_o { global $db; // If the user is not set, return the object if (!isset($this->user_id)) { return $this; } + if ($is_category) { + $fixed_price = null; + } // Check if the record already exists $this->removePriceIfExist($is_category, $product_or_category_id); - // If the percentage is 0, return the object - if ($percentage === 0) { + // If neither a discount nor a fixed product price is set, remove the record. + if ($percentage === 0 && $fixed_price === null) { return $this; } // Create a new record in the database - $sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage) VALUES ($this->user_id, " . (int)$is_category . ", '$product_or_category_id', $percentage)"; + $product_or_category_id = $db->escape_string((string)$product_or_category_id); + $fixed_price_sql = $fixed_price === null ? 'NULL' : (string)max(0, (int)$fixed_price); + $sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage, fixed_price) VALUES (" . (int)$this->user_id . ", " . (int)$is_category . ", '$product_or_category_id', " . (int)$percentage . ", $fixed_price_sql)"; $db->query($sql); + $this->markSearchDirty(); return $this; } private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void { global $db; - // Get the price override from the database - $sql = "SELECT * FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'"; - $result = $db->query($sql); - - if ($result->num_rows > 0) { - // Remove the record from the database - $sql = "DELETE FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'"; - $db->query($sql); - } + $product_or_category_id = $db->escape_string((string)$product_or_category_id); + $sql = "DELETE FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'"; + $db->query($sql); + $this->markSearchDirty(); } /** @@ -132,6 +139,49 @@ class user_price_overrides_o extends db return $percentage; } + public function getFixedPrice(bool $is_category, int|string $product_or_category_id): ?int + { + if ($is_category || !isset($this->user_id)) { + return null; + } + + $row = $this->getDirectPriceRow(false, (int)$product_or_category_id); + if ($row === null || $row['fixed_price'] === null) { + return null; + } + + return (int)$row['fixed_price']; + } + + public function getDirectPriceRow(bool $is_category, int|string $product_or_category_id): ?array + { + global $db; + + if (!isset($this->user_id)) { + return null; + } + + $product_or_category_id = $db->escape_string((string)$product_or_category_id); + $sql = "SELECT * FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id' LIMIT 1"; + $result = $db->query($sql); + if (!$result || $result->num_rows < 1) { + return null; + } + + $row = $result->fetch_assoc(); + $row['id'] = (int)$row['id']; + $row['user_id'] = (int)$row['user_id']; + $row['is_category'] = (bool)$row['is_category']; + $row['product_or_category_id'] = $is_category + ? (string)$row['product_or_category_id'] + : (int)$row['product_or_category_id']; + $row['percentage'] = (int)$row['percentage']; + $row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null + ? (int)$row['fixed_price'] + : null; + return $row; + } + /** * Get all the price overrides for the user * @return array @@ -153,6 +203,7 @@ class user_price_overrides_o extends db $row['is_category'] = (bool)$row['is_category']; $row['product_or_category_id'] = (int)$row['product_or_category_id']; $row['percentage'] = (int)$row['percentage']; + $row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null; $row['created_at'] = (string)$row['created_at']; $row['updated_at'] = (string)$row['updated_at']; // Add the row to the list @@ -167,10 +218,19 @@ class user_price_overrides_o extends db 'is_category' => true, 'product_or_category_id' => "global", 'percentage' => (int)$economic_user_global_discount, + 'fixed_price' => null, 'created_at' => "2021-01-01 00:00:00", 'updated_at' => "2021-01-01 00:00:00" ]; } return $prices; } -} \ No newline at end of file + + private function markSearchDirty(): void + { + try { + system_search_cache::markDirtyTable($this->table); + } catch (\Throwable) { + } + } +} diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index b9c4b677..14a1f7bf 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -981,6 +981,31 @@ class users_o extends db return $discount_percentage === null ? 0 : (int)$discount_percentage; } + public function getProductFixedPrice(int $product_id): ?int + { + self::requireSelected(); + 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 + { + self::requireSelected(); + + $fixed_price = $this->getProductFixedPrice($product_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); + if ($discount_percentage <= 0) { + return $base_price; + } + + return (int)round($base_price * (1 - ($discount_percentage / 100))); + } + /** @@ -1183,15 +1208,16 @@ class users_o extends db * @param int $object_id The ID of the object * @param int $discount_percentage The discount percentage * @param bool $is_category If the object is a category + * @param int|null $fixed_price The fixed product price, when set * @return void */ - public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false): void + public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false, ?int $fixed_price = null): void { $this->id = $user_id; // Get the user object properties $this->getObjectProperties(); // Set the custom price (key = 'custom_price') - $this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage); + $this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage, $fixed_price); } public function syncAllUsersEconomicCustomerDetails(): void diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 77e222e6..d9619476 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -3092,7 +3092,7 @@ paths: get: tags: - Users - summary: Get user discounts + summary: Get user discounts and product fixed prices operationId: getUserDiscounts parameters: - name: user_id @@ -3108,7 +3108,7 @@ paths: post: tags: - Users - summary: Set user discount + summary: Set user discount or product fixed price operationId: setUserDiscount requestBody: required: true @@ -3122,6 +3122,11 @@ paths: discount: {type: integer} object_id: {type: string} is_category: {type: boolean} + fixed_price: + type: integer + nullable: true + minimum: 0 + description: Optional product-only fixed price. Omit to preserve the current fixed price, send null to clear it. responses: '200': description: Success diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 5d84f670..57f08f4e 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -1733,9 +1733,9 @@ class InvoicingPeriodRoute $department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id); } if (!array_key_exists($product_id, $discount_cache)) { - $discount_cache[$product_id] = $user->getCustomPrice($product_id, false); + $discount_cache[$product_id] = $user->applyProductCustomerPricing($product_id, (int)$department_price_cache[$department_id][$product_id], false); } - $post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity; + $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/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index ca74179c..21454f33 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -187,7 +187,7 @@ class orderItemsRoute $this->put('/order/items', function () { // Require the user to be logged in - global $response; + global $response, $db; $this->requirePermission('edit_order_items'); // Get the user object $user = (new authentication())->get_user(); @@ -212,16 +212,34 @@ class orderItemsRoute $response->error('Quantity is required', 400); } - $orderItem = (new order_items_o())->getOrderItemById((int)$data['id']); + $orderItemId = (int)$data['id']; + $orderItem = (new order_items_o())->getOrderItemById($orderItemId); if (!$orderItem->exists()) { $response->error('Order item not found', 404); } - $product = (new products_o())->getProductById((int)$orderItem->product_id->value()); - if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') { + $orderItemContextResult = $db->query( + "SELECT oi.order_id, oi.product_id, p.name AS product_name, p.requires_note AS product_requires_note + FROM order_items oi + LEFT JOIN products p ON p.id = oi.product_id + WHERE oi.id = {$orderItemId} + LIMIT 1" + ); + $orderItemContext = $orderItemContextResult ? $orderItemContextResult->fetch_assoc() : null; + if ($orderItemContext === null) { + $response->error('Order item not found', 404); + } + if ($orderItemContext['product_id'] === null || $orderItemContext['product_name'] === null) { + $response->error('Product not found', 404); + } + if (products_o::productDataRequiresOrderItemNote([ + 'id' => (int)$orderItemContext['product_id'], + 'name' => (string)$orderItemContext['product_name'], + 'requires_note' => (bool)$orderItemContext['product_requires_note'], + ]) && trim((string)$data['notes']) === '') { $response->error('Notes is required for this product', 400); } - $order = (new orders_o())->getOrderById((int)$orderItem->order_id->value()); + $order = (new orders_o())->getOrderById((int)$orderItemContext['order_id']); if (!$order->exists()) { $response->error('Order not found', 404); } diff --git a/services/nginx/app/routes/userRoute.php b/services/nginx/app/routes/userRoute.php index 6d3e2b68..f43bc37d 100644 --- a/services/nginx/app/routes/userRoute.php +++ b/services/nginx/app/routes/userRoute.php @@ -103,6 +103,9 @@ class userRoute } // Check if the required fields are set $data = json_decode(file_get_contents('php://input'), true); + if (!is_array($data)) { + $response->error('Invalid request body', 400); + } if (!isset($data['discount'])) { // Log the incident (new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set'); @@ -122,14 +125,38 @@ class userRoute $response->error('No is_category set', 400); } $discount = (int)$data['discount']; + if ($discount < 0 || $discount > 100) { + $response->error('Discount must be between 0 and 100', 400); + } $is_category = (bool)$data['is_category']; if ($is_category) { $object_id = (string)$data['object_id']; } else { $object_id = (int)$data['object_id']; } + $fixed_price_is_set = array_key_exists('fixed_price', $data); + $fixed_price = null; + if ($fixed_price_is_set) { + if ($data['fixed_price'] === null || $data['fixed_price'] === '') { + $fixed_price = null; + } else { + $fixed_price_value = filter_var($data['fixed_price'], FILTER_VALIDATE_INT); + if ($fixed_price_value === false) { + $response->error('Invalid fixed price', 400); + } + $fixed_price = (int)$fixed_price_value; + } + if ($fixed_price !== null && $fixed_price < 0) { + $response->error('Fixed price must be zero or more', 400); + } + if ($is_category && $fixed_price !== null) { + $response->error('Fixed price can only be set for products', 400); + } + } elseif (!$is_category) { + $fixed_price = $targetUser->getProductFixedPrice((int)$object_id); + } // Set the custom price - $targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category); + $targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category, $fixed_price); try { (new economic_v2_versioning_service())->recordDiscountOverrideVersion( (int)$targetUser->id, @@ -145,7 +172,8 @@ class userRoute 'route' => '/superuser/user/discounts', 'method' => 'POST', 'actor_user_id' => (int)$user->id, - ] + ], + $fixed_price ); } catch (\Throwable $e) { (new logs_o())->add( diff --git a/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php index 233b21a8..ce911e15 100644 --- a/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php +++ b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php @@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` ( `is_category` TINYINT(1) NOT NULL, `object_id` VARCHAR(64) NOT NULL, `discount` INT NOT NULL, + `fixed_price` INT NULL DEFAULT NULL, `effective_from` DATETIME NOT NULL, `effective_to` DATETIME NULL, `source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test', diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 577c80f6..15f559dc 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -49,6 +49,67 @@ it('requires notes when adding the extraordinary chemistry product to an order', expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side'); }); +it('uses a product fixed price instead of the best discount when adding an order item', function (): void { + api_test_covers('POST /order/items', 'pricing'); + api_test_covers('GET /products', 'pricing'); + + $customer = api_fixtures()->createUser(['display_name' => 'Fixed Price Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Fixed Price Cashier']); + $category = api_fixtures()->createCategory(['name' => 'Fixed Price Category']); + $product = api_fixtures()->createProduct([ + 'name' => 'Fixed Price Product', + 'price' => 1000, + 'category' => $category['id'], + 'apply_category_discount' => 1, + ]); + + api_fixtures()->createPriceOverride([ + 'user_id' => $customer['id'], + 'is_category' => 1, + 'product_or_category_id' => (string)$category['id'], + 'percentage' => 80, + ]); + api_fixtures()->createPriceOverride([ + 'user_id' => $customer['id'], + 'is_category' => 0, + 'product_or_category_id' => (string)$product['id'], + 'percentage' => 10, + 'fixed_price' => 350, + ]); + + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'cashier_id' => $cashier['id'], + 'reference' => 'FIXED-PRICE', + ]); + $session = api_fixtures()->createUserSession(['add_order_items', 'list_products']); + + $productResponse = api_client()->get( + '/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'], + $session['headers'] + ); + $productResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect($productResponse->data()['price'] ?? null)->toBe(350); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['price'] ?? null)->toBe(350); +}); + it('does not allow clearing notes for order items whose product requires notes', function (): void { api_test_covers('PUT /order/items', 'validation'); @@ -63,7 +124,7 @@ it('does not allow clearing notes for order items whose product requires notes', ]); $product = api_fixtures()->createProduct([ 'id' => 902702, - 'name' => 'API Note Required Product', + 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, 'price' => 199, 'requires_note' => 1, ]); @@ -75,7 +136,7 @@ it('does not allow clearing notes for order items whose product requires notes', 'quantity' => 1, 'notes' => 'Initial note', ]); - $session = api_fixtures()->createUserSession(['edit_order_items']); + $session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']); api_client() ->put('/order/items', [ diff --git a/services/nginx/app/tests/Api/UserDiscountsApiTest.php b/services/nginx/app/tests/Api/UserDiscountsApiTest.php new file mode 100644 index 00000000..01a0539d --- /dev/null +++ b/services/nginx/app/tests/Api/UserDiscountsApiTest.php @@ -0,0 +1,119 @@ +createUser(['display_name' => 'Endpoint Fixed Price Customer']); + $product = api_fixtures()->createProduct([ + 'name' => 'Endpoint Fixed Price Product', + 'price' => 900, + ]); + $session = api_fixtures()->createUserSession([ + 'set_custom_price', + 'get_custom_prices_other', + ]); + + $findProductRow = function () use ($customer, $product, $session): array { + $response = api_client()->get( + '/superuser/user/discounts?user_id=' . $customer['id'], + $session['headers'] + ); + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + foreach ($response->data() as $row) { + if ((int)($row['product_or_category_id'] ?? 0) === (int)$product['id'] && !($row['is_category'] ?? false)) { + return $row; + } + } + + throw new RuntimeException('Expected product override row was not returned.'); + }; + + api_client() + ->post('/superuser/user/discounts', [ + 'user_id' => $customer['id'], + 'object_id' => $product['id'], + 'is_category' => false, + 'discount' => 20, + 'fixed_price' => 350, + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $row = $findProductRow(); + expect((int)$row['percentage'])->toBe(20); + expect((int)$row['fixed_price'])->toBe(350); + + api_client() + ->post('/superuser/user/discounts', [ + 'user_id' => $customer['id'], + 'object_id' => $product['id'], + 'is_category' => false, + 'discount' => 10, + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $row = $findProductRow(); + expect((int)$row['percentage'])->toBe(10); + expect((int)$row['fixed_price'])->toBe(350); + + api_client() + ->post('/superuser/user/discounts', [ + 'user_id' => $customer['id'], + 'object_id' => $product['id'], + 'is_category' => false, + 'discount' => 10, + 'fixed_price' => null, + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $row = $findProductRow(); + expect((int)$row['percentage'])->toBe(10); + expect($row['fixed_price'])->toBeNull(); +}); + +it('rejects invalid fixed price payloads for user discounts', function (): void { + api_test_covers('POST /superuser/user/discounts', 'validation'); + + $customer = api_fixtures()->createUser(['display_name' => 'Invalid Fixed Price Customer']); + $product = api_fixtures()->createProduct(['name' => 'Invalid Fixed Price Product']); + $category = api_fixtures()->createCategory(['name' => 'Invalid Fixed Price Category']); + $session = api_fixtures()->createUserSession(['set_custom_price']); + + api_client() + ->post('/superuser/user/discounts', [ + 'user_id' => $customer['id'], + 'object_id' => $product['id'], + 'is_category' => false, + 'discount' => 10, + 'fixed_price' => '12.5', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); + + api_client() + ->post('/superuser/user/discounts', [ + 'user_id' => $customer['id'], + 'object_id' => (string)$category['id'], + 'is_category' => true, + 'discount' => 10, + 'fixed_price' => 350, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 746e7885..9456b2ef 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -71,6 +71,7 @@ final class ApiFixtures $this->deleteRedisKey('`users`_' . $customerNumber . '_economic_customer_name'); $this->deleteRedisKey('users_' . $userId . '_economic_customer'); $this->deleteRedisKey('`users`_' . $userId . '_economic_customer'); + $this->deleteRedisKey('users_' . $userId . '_economic_customer_discount_percentage'); $this->deleteRedisPattern('perm:user:' . $userId . ':*'); $this->deleteRedisPattern('obj_prop:users:' . $userId . ':*'); }); @@ -78,6 +79,7 @@ final class ApiFixtures $economicName = (string)($attributes['economic_customer_name'] ?? $displayName); $this->seedCustomerNameCache($customerNumber, $economicName); $this->seedEconomicCustomerCache($userId, $customerNumber, $economicName, $email); + $this->seedEconomicCustomerDiscountCache($userId, (int)($attributes['economic_customer_discount_percentage'] ?? 0)); return [ 'id' => $userId, @@ -660,6 +662,33 @@ final class ApiFixtures return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []); } + /** + * @param array $attributes + * @return array + */ + public function createPriceOverride(array $attributes): array + { + $userId = (int)($attributes['user_id'] ?? 0); + $objectId = (string)($attributes['product_or_category_id'] ?? ''); + if ($userId <= 0 || $objectId === '') { + throw new RuntimeException('Price overrides require user_id and product_or_category_id.'); + } + + $overrideId = $this->insertRowWithExistingColumns('price_overrides', [ + 'user_id' => $userId, + 'is_category' => (int)($attributes['is_category'] ?? 0), + 'product_or_category_id' => $objectId, + 'percentage' => (int)($attributes['percentage'] ?? 0), + 'fixed_price' => $attributes['fixed_price'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + ]); + + $this->cleanup->add(fn() => $this->deleteById('price_overrides', $overrideId)); + + return array_merge(['id' => $overrideId], $this->fetchRowById('price_overrides', $overrideId) ?? []); + } + public function linkDepartmentCategory(int $departmentId, int $categoryId): int { $linkId = $this->insertRow('department_categories', [ @@ -1793,6 +1822,11 @@ final class ApiFixtures $this->setRedisJson('`users`_' . $userId . '_economic_customer', $payload); } + private function seedEconomicCustomerDiscountCache(int $userId, int $discountPercentage): void + { + $this->setRedisValue('users_' . $userId . '_economic_customer_discount_percentage', (string)$discountPercentage); + } + /** * @param array $data */ @@ -2164,6 +2198,16 @@ final class ApiFixtures $this->cleanup->add(fn() => $this->deleteRedisKey($key)); } + private function setRedisValue(string $key, string $value): void + { + if ($this->redis === null) { + throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.'); + } + + $this->redis->set($key, $value); + $this->cleanup->add(fn() => $this->deleteRedisKey($key)); + } + private function deleteRedisKey(string $key): void { if ($this->redis === null) { diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 6432a005..42eaa802 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -21,6 +21,7 @@ final class ApiSchemaBootstrap $this->ensureDepartmentArchiveSchema(); $this->ensureOrderInvoiceCollectionSchema(); + $this->ensurePriceOverrideSchema(); foreach ($this->viewStatements() as $name => $sql) { $this->execute($name, $sql); @@ -732,6 +733,7 @@ CREATE TABLE IF NOT EXISTS `price_overrides` ( `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`), @@ -932,6 +934,16 @@ SQL, } } + private function ensurePriceOverrideSchema(): void + { + if (!$this->columnExists('price_overrides', 'fixed_price')) { + $this->execute( + 'price_overrides.fixed_price', + 'ALTER TABLE `price_overrides` ADD COLUMN `fixed_price` INT NULL DEFAULT NULL AFTER `percentage`' + ); + } + } + private function columnExists(string $table, string $column): bool { $table = $this->db->real_escape_string($table); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceProductFixedPriceTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceProductFixedPriceTest.php new file mode 100644 index 00000000..d516a184 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceProductFixedPriceTest.php @@ -0,0 +1,167 @@ + $customer_number, + 'is_category' => 0, + 'object_id' => '42', + 'discount' => 10, + 'fixed_price' => 350, + ]; + } + + if ($is_category) { + return [ + 'customer_number' => $customer_number, + 'is_category' => 1, + 'object_id' => (string)$object_id, + 'discount' => 80, + 'fixed_price' => null, + ]; + } + + return null; + } + + public function runBestEffortBackfill(): array + { + return []; + } + } +} + +if (!class_exists('TestableEconomicV2ProductFixedPriceDistributionService')) { + class TestableEconomicV2ProductFixedPriceDistributionService extends economic_v2_distribution_service + { + public function __construct() + { + parent::__construct(new FakeEconomicV2ProductFixedPriceVersioningService()); + } + + public function exposeCalculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float + { + return $this->calculateOrderOriginalPrice($order_items, $customer_number, $department_id, $timestamp); + } + + protected function ensureVersionHistoryAvailable(array $areas): void + { + } + + protected function fetchOrdersInRange(string $from_ts, string $to_ts): array + { + return [[ + 'id' => 1001, + 'customer_id' => 35131752, + 'department_id' => 7, + 'created_at' => '2026-01-05 12:00:00', + 'include_in_invoice' => 1, + ]]; + } + + protected function fetchOrderItemsByOrderIds(array $order_ids): array + { + return [ + 1001 => [[ + 'product_id' => 42, + 'quantity' => 2, + 'price' => 0, + ]], + ]; + } + + protected function getProductDepartmentPrice(int $product_id, int $department_id): float + { + return 1000.0; + } + + protected function isOrderEligible(array $order): bool + { + return true; + } + + protected function shouldIncludeCustomerNumber(int $customer_number): bool + { + return $customer_number > 0; + } + + protected function parseDepartmentMap(array $department_map): array + { + $parsed = []; + foreach ($department_map as $department_id => $amount) { + $parsed['Department ' . $department_id] = round((float)$amount, 5); + } + return $parsed; + } + + protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array + { + return [ + 'id' => $customer_number, + 'customer_number' => $customer_number, + 'customer_name' => 'Customer ' . $customer_number, + 'transactions' => array_values($transaction_map), + 'requires_action' => false, + 'meta' => [], + ]; + } + + protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array + { + return [ + 'id' => $order_id, + 'date' => $created_at, + 'amount' => round((float)($amount ?? 0.0), 5), + 'booked' => true, + 'department_id' => $department_id, + 'excluded' => !($included ?? true), + ]; + } + } +} + +it('uses product fixed prices before discounts in customer price distributions', function (): void { + $service = new TestableEconomicV2ProductFixedPriceDistributionService(); + + $result = $service->getCustomerPricesDistribution('2026-01-01', '2026-01-31'); + + expect($result['collective_results']['total_discount_amount'])->toBe(1300.0); + expect($result['collective_results']['department_discount_totals'][7])->toBe(1300.0); + expect($result['customers'][0]['meta']['customer_prices']['discount_total'])->toBe(1300.0); + expect($result['customers'][0]['transactions'][0]['amount'])->toBe(1300.0); + + expect($service->exposeCalculateOrderOriginalPrice( + [[ + 'product_id' => 42, + 'quantity' => 2, + 'price' => 0, + ]], + 35131752, + 7, + '2026-01-05 12:00:00' + ))->toBe(700.0); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index 56c18f8f..922f20e5 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -579,6 +579,33 @@ it('uses the highest customer-specific discount in expected price breakdowns', f ]); }); +it('uses a product fixed price before customer discounts in expected price breakdowns', function (): void { + $row = [ + 'customer_number' => 0, + 'product_base_price' => 1000, + 'department_price' => null, + 'product_fixed_price' => 350, + 'product_discount_percentage' => 10, + 'category_discount_percentage' => 80, + 'apply_category_discount' => 1, + ]; + + $expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]); + $breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]); + + expect($expected)->toBe(350); + expect($breakdown)->toMatchArray([ + 'product_price' => 1000, + 'effective_base_price' => 1000, + 'product_fixed_price' => 350, + 'product_discount_percentage' => 10, + 'category_discount_percentage' => 80, + 'economic_customer_discount_percentage' => 0, + 'applied_discount_percentage' => 0, + 'expected_price' => 350, + ]); +}); + it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void { $service = invoice_period_flag_service_instance(); $reflection = new ReflectionClass(invoice_period_flag_service::class);