Merge pull request #290 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing enforcement
This commit is contained in:
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
||||
AFTER archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
|
||||
@@ -688,6 +688,7 @@ class invoice_period_flag_service
|
||||
o.po AS order_po,
|
||||
o.notes AS order_notes,
|
||||
o.department_id,
|
||||
d.custom_pricing_only AS department_custom_pricing_only,
|
||||
o.reg_1,
|
||||
o.invoice_collection_id,
|
||||
o.wash_id,
|
||||
@@ -720,6 +721,7 @@ class invoice_period_flag_service
|
||||
GROUP BY customer_number
|
||||
) u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN departments d ON d.id = o.department_id
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
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
|
||||
@@ -1921,19 +1923,26 @@ class invoice_period_flag_service
|
||||
|
||||
private function calculateExpectedPrice(array $row): int
|
||||
{
|
||||
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
$base = $row['department_price'] !== null
|
||||
? (int)$row['department_price']
|
||||
: ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||
$discount = $customMissingPrice ? 0 : $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||
return (int)round($base * (1 - ($discount / 100)));
|
||||
}
|
||||
|
||||
private function priceBreakdown(array $row, int $expected): array
|
||||
{
|
||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||
$discount = $this->discountBreakdown($row);
|
||||
if ($customMissingPrice) {
|
||||
$discount['applied_discount_percentage'] = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'product_price' => (int)($row['product_base_price'] ?? 0),
|
||||
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_base_price' => $base,
|
||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||
@@ -1944,6 +1953,11 @@ class invoice_period_flag_service
|
||||
];
|
||||
}
|
||||
|
||||
private function isCustomMissingDepartmentPrice(array $row): bool
|
||||
{
|
||||
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
private function discountBreakdown(array $row): array
|
||||
{
|
||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace classes;
|
||||
|
||||
use mysqli;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class limited_backoffice_service
|
||||
@@ -247,6 +248,7 @@ class limited_backoffice_service
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
departments_schema_bootstrap::ensureTables();
|
||||
limited_backoffice_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
@@ -356,7 +358,7 @@ class limited_backoffice_service
|
||||
|
||||
$in = implode(',', array_map('intval', $departmentIds));
|
||||
$sql = "
|
||||
SELECT `id`, `name`, `description`, `visible`, `archived`
|
||||
SELECT `id`, `name`, `description`, `visible`, `archived`, `custom_pricing_only`
|
||||
FROM `departments`
|
||||
WHERE `id` IN ($in)
|
||||
ORDER BY `order_priority` ASC, `name` ASC, `id` ASC
|
||||
@@ -371,6 +373,7 @@ class limited_backoffice_service
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'visible' => (bool)($row['visible'] ?? false),
|
||||
'archived' => (bool)($row['archived'] ?? false),
|
||||
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
@@ -386,8 +389,9 @@ class limited_backoffice_service
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
|
||||
$catalog = $this->departmentProductCatalog($departmentId);
|
||||
if ($catalog['missing_products'] !== []) {
|
||||
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
|
||||
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
|
||||
if (!$customPricingOnly && $catalog['missing_products'] !== []) {
|
||||
throw new limited_backoffice_exception('Department price setup is incomplete.', 409, [
|
||||
'message' => 'Department price setup is incomplete.',
|
||||
'code' => 'department_price_setup_required',
|
||||
@@ -419,7 +423,8 @@ class limited_backoffice_service
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
|
||||
$catalog = $this->departmentProductCatalog($departmentId);
|
||||
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
|
||||
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
|
||||
if ($catalog['required_product_ids'] === []) {
|
||||
throw new limited_backoffice_exception('Department has no products configured.', 409);
|
||||
}
|
||||
@@ -436,7 +441,7 @@ class limited_backoffice_service
|
||||
sort($providedProductIds);
|
||||
$missingProductIds = array_values(array_diff($requiredProductIds, $providedProductIds));
|
||||
|
||||
if ($missingProductIds !== []) {
|
||||
if (!$customPricingOnly && $missingProductIds !== []) {
|
||||
throw new limited_backoffice_exception('Price is required for every department product.', 400, [
|
||||
'message' => 'Price is required for every department product.',
|
||||
'missing_product_ids' => $missingProductIds,
|
||||
@@ -789,13 +794,13 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,name:string,description:string}
|
||||
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
|
||||
*/
|
||||
private function fetchDepartment(int $departmentId): ?array
|
||||
{
|
||||
global $db;
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `id`, `name`, `description` FROM `departments` WHERE `id` = ? LIMIT 1'
|
||||
'SELECT `id`, `name`, `description`, `custom_pricing_only` FROM `departments` WHERE `id` = ? LIMIT 1'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to load department.', 500);
|
||||
@@ -814,13 +819,14 @@ class limited_backoffice_service
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{categories:array<int,array<string,mixed>>,missing_products:array<int,array<string,mixed>>,required_product_ids:array<int,int>}
|
||||
*/
|
||||
private function departmentProductCatalog(int $departmentId): array
|
||||
private function departmentProductCatalog(int $departmentId, bool $customPricingOnly = false): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -881,10 +887,12 @@ class limited_backoffice_service
|
||||
'id' => $productId,
|
||||
'name' => (string)$row['product_name'],
|
||||
'description' => (string)($row['product_description'] ?? ''),
|
||||
'price' => $row['department_price'] === null ? null : (int)$row['department_price'],
|
||||
'price' => $row['department_price'] === null
|
||||
? ($customPricingOnly ? products_o::CUSTOM_PRICING_MISSING_PRICE : null)
|
||||
: (int)$row['department_price'],
|
||||
];
|
||||
|
||||
if ($row['department_price_id'] === null) {
|
||||
if ($row['department_price_id'] === null && !$customPricingOnly) {
|
||||
$missing[] = [
|
||||
'id' => $productId,
|
||||
'name' => (string)$row['product_name'],
|
||||
|
||||
@@ -22,6 +22,7 @@ class departments_o extends db
|
||||
public object_property $dimension; // The dimension of the department
|
||||
public object_property $visible; // The visibility of the department
|
||||
public object_property $archived; // Whether the department is archived
|
||||
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
|
||||
public object_property $branding; // The branding of the department
|
||||
public object_property $longitude; // The longitude of the department (Can be null)
|
||||
public object_property $latitude; // The latitude of the department (Can be null)
|
||||
@@ -107,6 +108,7 @@ class departments_o extends db
|
||||
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
||||
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
|
||||
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
|
||||
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
|
||||
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
|
||||
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
|
||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||
@@ -185,6 +187,12 @@ class departments_o extends db
|
||||
return $department;
|
||||
}
|
||||
|
||||
public function isCustomPricingOnly(int $department_id): bool
|
||||
{
|
||||
$department = $this->getDepartmentById($department_id);
|
||||
return (bool)(int)($department['custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the price of a product in a department
|
||||
* @param int $department_id
|
||||
|
||||
@@ -168,12 +168,14 @@ class order_items_o extends db
|
||||
// Get the order
|
||||
$order = (new orders_o())->getOrderById($order_id);
|
||||
// Get the product price
|
||||
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
||||
$product = (new products_o())->getProductById($product_id);
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
||||
$price = $priceResolution['price'];
|
||||
|
||||
// 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) {
|
||||
if ($discount && !products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
$price = $price - ($price * $discount / 100);
|
||||
}
|
||||
|
||||
@@ -354,4 +356,4 @@ class order_items_o extends db
|
||||
{
|
||||
return (new products_o())->select((int)$this->product_id->value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1428,7 +1428,8 @@ class orders_o extends db
|
||||
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
|
||||
$order_item->reference->set('');
|
||||
// Get the product price based on the department
|
||||
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||
$product_price = (int)$priceResolution['price']; // Get the department price for the product
|
||||
// Get the customers custom price discount percentage
|
||||
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
|
||||
if (!$user->exists()) {
|
||||
@@ -1436,7 +1437,9 @@ class orders_o extends db
|
||||
}
|
||||
$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
|
||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the 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
|
||||
@@ -1503,10 +1506,14 @@ class orders_o extends db
|
||||
if (!$current_user->exists()) {
|
||||
throw new Exception('No current user found');
|
||||
}
|
||||
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||
$price = (int)$priceResolution['price']; // 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
|
||||
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
return $price;
|
||||
}
|
||||
return (int)round($price * (1 - ($discount_percentage / 100)));
|
||||
}
|
||||
|
||||
@@ -1589,13 +1596,17 @@ class orders_o extends db
|
||||
$product_id = (int)$item['product_id'];
|
||||
if (!isset($department_price_cache[$product_id])) {
|
||||
$product = (new products_o())->select($product_id);
|
||||
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
|
||||
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
|
||||
}
|
||||
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;
|
||||
$unitPrice = (int)$department_price_cache[$product_id]['price'];
|
||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
|
||||
$unitPrice = (int)round($unitPrice * (1 - ($discount / 100)));
|
||||
}
|
||||
$post_discount = $unitPrice * $quantity;
|
||||
$total += $post_discount;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ class products_o extends db
|
||||
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
|
||||
public const PRICE_SOURCE_DEPARTMENT = 'department';
|
||||
public const PRICE_SOURCE_DEFAULT = 'default';
|
||||
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
|
||||
public const PRICE_SOURCE_KEY = '_department_price_source';
|
||||
|
||||
/**
|
||||
* The name of the product
|
||||
@@ -255,24 +260,41 @@ class products_o extends db
|
||||
* @param int $department_id
|
||||
* @return array
|
||||
*/
|
||||
public function applyDepartmentPricing(array $products, int $department_id): array
|
||||
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
|
||||
{
|
||||
global $db;
|
||||
$department_id = $db->escape_string($department_id);
|
||||
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
|
||||
$result = $db->query($sql);
|
||||
$prices = $db->fetch_all($result);
|
||||
$priceLookup = [];
|
||||
foreach ($prices as $price) {
|
||||
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
|
||||
}
|
||||
|
||||
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
|
||||
foreach ( $products as $key => $product ) {
|
||||
foreach ( $prices as $price ) {
|
||||
if ((int)$product['id'] === (int)$price['product_id']) {
|
||||
$products[$key]['price'] = $price['price'];
|
||||
}
|
||||
$productId = (int)($product['id'] ?? 0);
|
||||
$source = self::PRICE_SOURCE_DEFAULT;
|
||||
if (array_key_exists($productId, $priceLookup)) {
|
||||
$products[$key]['price'] = $priceLookup[$productId];
|
||||
$source = self::PRICE_SOURCE_DEPARTMENT;
|
||||
} elseif ($customPricingOnly) {
|
||||
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
|
||||
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
|
||||
}
|
||||
|
||||
if ($includePriceSource) {
|
||||
$products[$key][self::PRICE_SOURCE_KEY] = $source;
|
||||
}
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
public function getDepartmentPrice(int $department_id): int
|
||||
/**
|
||||
* @return array{price:int,source:string}
|
||||
*/
|
||||
public function getDepartmentPriceResolution(int $department_id): array
|
||||
{
|
||||
global $db;
|
||||
$department_id = $db->escape_string($department_id);
|
||||
@@ -281,10 +303,27 @@ class products_o extends db
|
||||
$prices = $db->fetch_all($result);
|
||||
// Check if the product has a department price
|
||||
if (count($prices) > 0) {
|
||||
return $prices[0]['price'];
|
||||
return [
|
||||
'price' => (int)$prices[0]['price'],
|
||||
'source' => self::PRICE_SOURCE_DEPARTMENT,
|
||||
];
|
||||
}
|
||||
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
|
||||
return [
|
||||
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
|
||||
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
|
||||
];
|
||||
}
|
||||
// Return the default price
|
||||
return $this->price->value();
|
||||
return [
|
||||
'price' => (int)$this->price->value(),
|
||||
'source' => self::PRICE_SOURCE_DEFAULT,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDepartmentPrice(int $department_id): int
|
||||
{
|
||||
return $this->getDepartmentPriceResolution($department_id)['price'];
|
||||
}
|
||||
|
||||
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
||||
@@ -303,12 +342,26 @@ class products_o extends db
|
||||
// Get the customer's discount percentage
|
||||
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
|
||||
// Apply the discount to the product price
|
||||
if ($discount_percentage > 0) {
|
||||
if ($discount_percentage > 0 && ($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
|
||||
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
|
||||
}
|
||||
unset($product[self::PRICE_SOURCE_KEY]);
|
||||
return $product;
|
||||
}
|
||||
|
||||
public static function stripDepartmentPriceSources(array $products): array
|
||||
{
|
||||
return array_map(static function (array $product): array {
|
||||
unset($product[self::PRICE_SOURCE_KEY]);
|
||||
return $product;
|
||||
}, $products);
|
||||
}
|
||||
|
||||
public static function priceResolutionIsCustomMissing(array $resolution): bool
|
||||
{
|
||||
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
|
||||
}
|
||||
|
||||
public function getSubscriptionMonthlyPrice(): int
|
||||
{
|
||||
// Subscription price (for 2 washes per month) is 1.2 times the normal price
|
||||
|
||||
@@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute
|
||||
$product_cache[$product_id] = (new products_o())->select($product_id);
|
||||
}
|
||||
if (!isset($department_price_cache[$department_id][$product_id])) {
|
||||
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
|
||||
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
|
||||
}
|
||||
if (!array_key_exists($product_id, $discount_cache)) {
|
||||
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
|
||||
}
|
||||
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
|
||||
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
|
||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
|
||||
$unit_price = (int)round($unit_price * (1 - ($discount_cache[$product_id] / 100)));
|
||||
}
|
||||
$post_discount = $unit_price * $quantity;
|
||||
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ class departmentsRoute
|
||||
'economic_department_id',
|
||||
'visible',
|
||||
'archived',
|
||||
'custom_pricing_only',
|
||||
'longitude',
|
||||
'latitude',
|
||||
])
|
||||
@@ -123,6 +124,12 @@ class departmentsRoute
|
||||
'latitude' => (float)$department['latitude'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
];
|
||||
if (
|
||||
$user->hasPermission('superuser_fetch_department')
|
||||
|| $user->hasPermission('edit_department')
|
||||
) {
|
||||
$tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
|
||||
}
|
||||
// If the user has the permission to view the slack webhook, add it to the response
|
||||
if ($user->hasPermission('view_slack_webhook')) {
|
||||
$tmp_department['slack_webhook'] = $department['slack_webhook'];
|
||||
@@ -220,6 +227,9 @@ class departmentsRoute
|
||||
if (self::isParametersSet(['archived'])) {
|
||||
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
|
||||
}
|
||||
if (self::isParametersSet(['custom_pricing_only'])) {
|
||||
$department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only')));
|
||||
}
|
||||
$department->objectChanged();
|
||||
// Log the incident
|
||||
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
|
||||
|
||||
@@ -53,6 +53,19 @@ class productsRoute
|
||||
return null;
|
||||
}
|
||||
|
||||
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
|
||||
{
|
||||
if (!$user instanceof users_o || $departmentId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasPermission('superuser_fetch_department')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requirePermission('department_access_' . $departmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category (ID) if the category parameter is provided (In the request 'category')
|
||||
* @return int|null
|
||||
@@ -91,12 +104,14 @@ class productsRoute
|
||||
// Check if the departmentId is set
|
||||
if ($departmentId) {
|
||||
// Apply the departments unique pricing
|
||||
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
|
||||
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
|
||||
}
|
||||
// Check if the customer is set
|
||||
if ($customer !== null) {
|
||||
// Apply the customers unique discounts
|
||||
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
|
||||
} else {
|
||||
$products = products_o::stripDepartmentPriceSources($products);
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
@@ -197,6 +212,7 @@ class productsRoute
|
||||
// Define the variables
|
||||
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
||||
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||
// Check if the "final_price" parameter is set, and true.
|
||||
|
||||
@@ -231,6 +231,7 @@ it('updates departments through the real endpoint', function (): void {
|
||||
'description' => 'Updated description',
|
||||
'order_priority' => 5,
|
||||
'archived' => true,
|
||||
'custom_pricing_only' => true,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
@@ -246,6 +247,7 @@ it('updates departments through the real endpoint', function (): void {
|
||||
expect($row['description'] ?? null)->toBe('Updated description');
|
||||
expect((int)($row['order_priority'] ?? 0))->toBe(5);
|
||||
expect((int)($row['archived'] ?? 0))->toBe(1);
|
||||
expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid department update requests', function (): void {
|
||||
|
||||
@@ -285,6 +285,85 @@ it('fails price setup gaps without exposing product defaults', function (): void
|
||||
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
|
||||
});
|
||||
|
||||
it('defaults missing custom-only department prices to sentinel without exposing fallback prices', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments', 'happy');
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Limited Custom Pricing Only',
|
||||
'custom_pricing_only' => 1,
|
||||
]);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Other Pricing']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Custom Pricing Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Custom Missing Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 87654,
|
||||
]);
|
||||
$otherProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Custom Missing Other Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 76543,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321);
|
||||
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$otherProduct['id'], 5432);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$departments = api_client()->get('/limited-backoffice/departments', $session['headers']);
|
||||
$departments
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($departments->data()[0]['custom_pricing_only'] ?? null)->toBeTrue();
|
||||
|
||||
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->body)->not->toContain('87654');
|
||||
expect($response->body)->not->toContain('76543');
|
||||
expect($response->body)->not->toContain('4321');
|
||||
expect($response->body)->not->toContain('5432');
|
||||
expect($response->data()['department']['custom_pricing_only'] ?? null)->toBeTrue();
|
||||
$products = [];
|
||||
foreach ($response->data()['categories'] as $departmentCategory) {
|
||||
foreach ($departmentCategory['products'] as $departmentProduct) {
|
||||
$products[(int)$departmentProduct['id']] = $departmentProduct;
|
||||
}
|
||||
}
|
||||
expect($products[(int)$product['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
expect($products[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
|
||||
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$product['id'], 'price' => 2222],
|
||||
],
|
||||
], $session['headers']);
|
||||
$updated
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$updatedProducts = [];
|
||||
foreach ($updated->data()['categories'] as $departmentCategory) {
|
||||
foreach ($departmentCategory['products'] as $departmentProduct) {
|
||||
$updatedProducts[(int)$departmentProduct['id']] = $departmentProduct;
|
||||
}
|
||||
}
|
||||
expect($updated->body)->not->toContain('87654');
|
||||
expect($updated->body)->not->toContain('76543');
|
||||
expect($updated->body)->not->toContain('4321');
|
||||
expect($updated->body)->not->toContain('5432');
|
||||
expect($updatedProducts[(int)$product['id']]['price'] ?? null)->toBe(2222);
|
||||
expect($updatedProducts[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
});
|
||||
|
||||
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
|
||||
|
||||
|
||||
@@ -36,6 +36,41 @@ function post_order_item(array $order, array $product, array $headers, array $ov
|
||||
], $overrides), $headers);
|
||||
}
|
||||
|
||||
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
|
||||
VALUES (?, 0, ?, ?)'
|
||||
);
|
||||
$productIdText = (string)$productId;
|
||||
$statement->bind_param('isi', $userId, $productIdText, $percentage);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
api_fixtures()->cleanupDeleteWhere('price_overrides', [
|
||||
'user_id' => $userId,
|
||||
'is_category' => 0,
|
||||
'product_or_category_id' => $productIdText,
|
||||
]);
|
||||
}
|
||||
|
||||
function custom_pricing_only_department_price(int $departmentId, int $productId, int $price): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
|
||||
);
|
||||
$statement->bind_param('iii', $departmentId, $productId, $price);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
|
||||
api_test_covers('POST /order/items', 'validation');
|
||||
|
||||
@@ -266,3 +301,77 @@ it('only allows tank cleaning products when the customer has the only tank clean
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
|
||||
api_test_covers('GET /products', 'happy');
|
||||
api_test_covers('POST /order/items', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Custom Pricing Products',
|
||||
'custom_pricing_only' => 1,
|
||||
]);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Custom Pricing Missing Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 12345,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
|
||||
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
|
||||
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'list_products',
|
||||
'add_order_items',
|
||||
'department_access_' . (int)$department['id'],
|
||||
]);
|
||||
|
||||
$productResponse = api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id']
|
||||
. '&department_id=' . (int)$department['id']
|
||||
. '&customer_id=' . (int)$customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
$productResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($productResponse->body)->not->toContain('12345');
|
||||
expect($productResponse->body)->not->toContain('3333');
|
||||
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
|
||||
api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id']
|
||||
. '&department_id=' . (int)$otherDepartment['id'],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'CUSTOM-ONLY-ORDER',
|
||||
]);
|
||||
|
||||
$orderItem = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$orderItem
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
});
|
||||
|
||||
@@ -132,6 +132,7 @@ final class ApiFixtures
|
||||
'branding' => (int)($attributes['branding'] ?? 0),
|
||||
'visible' => (int)($attributes['visible'] ?? 1),
|
||||
'archived' => (int)($attributes['archived'] ?? 0),
|
||||
'custom_pricing_only' => (int)($attributes['custom_pricing_only'] ?? 0),
|
||||
'latitude' => $attributes['latitude'] ?? 0.0,
|
||||
'longitude' => $attributes['longitude'] ?? 0.0,
|
||||
'order_priority' => (int)($attributes['order_priority'] ?? 0),
|
||||
@@ -1709,6 +1710,17 @@ final class ApiFixtures
|
||||
$this->cleanup->add(fn() => $this->deleteWhere($table, $conditions));
|
||||
}
|
||||
|
||||
public function cacheEconomicCustomerDiscountPercentage(int $userId, int $discountPercentage): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.');
|
||||
}
|
||||
|
||||
$key = 'users_' . $userId . '_economic_customer_discount_percentage';
|
||||
$this->redis->set($key, (string)$discountPercentage);
|
||||
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
|
||||
}
|
||||
|
||||
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
|
||||
{
|
||||
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
|
||||
|
||||
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS `departments` (
|
||||
`branding` INT NULL DEFAULT NULL,
|
||||
`visible` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`archived` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||
`longitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||
`order_priority` INT NOT NULL DEFAULT 0,
|
||||
@@ -892,6 +893,13 @@ SQL,
|
||||
'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)'
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->columnExists('departments', 'custom_pricing_only')) {
|
||||
$this->execute(
|
||||
'departments.custom_pricing_only',
|
||||
'ALTER TABLE `departments` ADD COLUMN `custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0 AFTER `archived`'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureOrderInvoiceCollectionSchema(): void
|
||||
|
||||
@@ -618,6 +618,33 @@ it('uses a preloaded e-conomic global discount in expected price breakdowns', fu
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the custom-only sentinel without discounts when department price is missing', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 35131752,
|
||||
'user_id' => 411,
|
||||
'product_base_price' => 100,
|
||||
'department_price' => null,
|
||||
'department_custom_pricing_only' => 1,
|
||||
'product_discount_percentage' => 50,
|
||||
'category_discount_percentage' => 25,
|
||||
'apply_category_discount' => 0,
|
||||
];
|
||||
|
||||
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
|
||||
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
|
||||
|
||||
expect($expected)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
expect($breakdown)->toMatchArray([
|
||||
'product_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||
'department_price' => null,
|
||||
'effective_base_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||
'product_discount_percentage' => 50,
|
||||
'category_discount_percentage' => 0,
|
||||
'applied_discount_percentage' => 0,
|
||||
'expected_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 35131752,
|
||||
|
||||
Reference in New Issue
Block a user