Add customer product fixed price overrides
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+167
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user