Add customer product fixed price overrides

This commit is contained in:
Jeppe Bundgaard
2026-07-06 12:52:33 +02:00
parent 94c3654240
commit f02dfd8c9c
23 changed files with 738 additions and 59 deletions
@@ -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;
}
@@ -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;
}
}
@@ -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]++;
}
}
@@ -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);
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for customer product price overrides.
*/
class price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureColumns(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'price_overrides')) {
return;
}
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
$db->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);
}
}
@@ -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,
@@ -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),
+3 -6
View File
@@ -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());
}
}
}
+3 -9
View File
@@ -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;
}
+1 -6
View File
@@ -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;
}
@@ -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;
}
}
private function markSearchDirty(): void
{
try {
system_search_cache::markDirtyTable($this->table);
} catch (\Throwable) {
}
}
}
+28 -2
View File
@@ -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
+7 -2
View File
@@ -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
@@ -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);
}
+23 -5
View File
@@ -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);
}
+30 -2
View File
@@ -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(
@@ -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',
@@ -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', [
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('sets preserves and clears product fixed prices through the user discounts endpoint', function (): void {
api_test_covers('POST /superuser/user/discounts', 'pricing');
api_test_covers('GET /superuser/user/discounts', 'pricing');
$customer = api_fixtures()->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);
});
@@ -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<string, mixed> $attributes
* @return array<string, mixed>
*/
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<string, mixed> $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) {
@@ -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);
@@ -0,0 +1,167 @@
<?php
app_require('classes/economic_v2_versioning_service.php');
app_require('classes/economic_v2_distribution_service.php');
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
if (!class_exists('FakeEconomicV2ProductFixedPriceVersioningService')) {
class FakeEconomicV2ProductFixedPriceVersioningService extends economic_v2_versioning_service
{
public function __construct()
{
}
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
{
return null;
}
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
{
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
{
if (!$is_category && (int)$object_id === 42) {
return [
'customer_number' => $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);
});
@@ -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);