Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
430c90cbca | ||
|
|
a8fba73d99 | ||
|
|
669759461d | ||
|
|
215c8d0fbb |
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
if ($message !== null) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$context = self::loadOrderProductContext($orderId, $productId);
|
||||
if ($context === null) {
|
||||
return null;
|
||||
}
|
||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||
? self::ONLY_TANKCLEANING_MESSAGE
|
||||
: null;
|
||||
}
|
||||
|
||||
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||
{
|
||||
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||
}
|
||||
|
||||
public static function isTankCleaningProductRow(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1 || $productId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
o.customer_id AS customer_number,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name,
|
||||
p.category AS product_category,
|
||||
c.name AS category_name,
|
||||
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||
FROM orders o
|
||||
LEFT JOIN products p ON p.id = {$productId}
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||
WHERE o.id = {$orderId}
|
||||
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
));
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2036,8 +2036,7 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
|
||||
@@ -112,6 +112,124 @@ class limited_backoffice_service
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{group:string,capability:string}>
|
||||
*/
|
||||
private const ROLE_PERMISSION_CAPABILITIES = [
|
||||
'user' => [
|
||||
'group' => 'account',
|
||||
'capability' => 'sign_in',
|
||||
],
|
||||
'permissions_list_own' => [
|
||||
'group' => 'account',
|
||||
'capability' => 'view_own_permissions',
|
||||
],
|
||||
'list_orders' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'view_orders',
|
||||
],
|
||||
'add_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'create_orders',
|
||||
],
|
||||
'edit_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'edit_orders',
|
||||
],
|
||||
'delete_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'delete_orders',
|
||||
],
|
||||
'list_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'view_order_items',
|
||||
],
|
||||
'add_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'create_order_items',
|
||||
],
|
||||
'edit_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'update_order_lines',
|
||||
],
|
||||
'delete_order_items' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'remove_order_lines',
|
||||
],
|
||||
'charge_order' => [
|
||||
'group' => 'orders',
|
||||
'capability' => 'charge_orders',
|
||||
],
|
||||
'list_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'view_department_bookings',
|
||||
],
|
||||
'list_own_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'view_own_bookings',
|
||||
],
|
||||
'edit_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'update_bookings',
|
||||
],
|
||||
'add_booking' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'create_bookings',
|
||||
],
|
||||
'complete_bookings' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'mark_bookings_complete',
|
||||
],
|
||||
'resend_booking_confirmations' => [
|
||||
'group' => 'bookings',
|
||||
'capability' => 'send_booking_confirmations',
|
||||
],
|
||||
'department_timebookings_entries_get' => [
|
||||
'group' => 'time_bookings',
|
||||
'capability' => 'view_time_booking_entries',
|
||||
],
|
||||
'department_timebookings_entries_post' => [
|
||||
'group' => 'time_bookings',
|
||||
'capability' => 'create_time_booking_entries',
|
||||
],
|
||||
'department_timebookings_entries_put' => [
|
||||
'group' => 'time_bookings',
|
||||
'capability' => 'edit_time_booking_entries',
|
||||
],
|
||||
'statistics_orders_new' => [
|
||||
'group' => 'reports',
|
||||
'capability' => 'view_order_statistics',
|
||||
],
|
||||
'statistics_bookings_new' => [
|
||||
'group' => 'reports',
|
||||
'capability' => 'view_booking_statistics',
|
||||
],
|
||||
self::PERMISSION_ACCESS => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'open_limited_backoffice',
|
||||
],
|
||||
self::PERMISSION_MANAGE_PRICES => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'manage_department_prices',
|
||||
],
|
||||
self::PERMISSION_MANAGE_EMPLOYEES => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'manage_employee_access',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ROLE_PERMISSION_GROUP_ORDER = [
|
||||
'account',
|
||||
'orders',
|
||||
'bookings',
|
||||
'time_bookings',
|
||||
'reports',
|
||||
'limited_backoffice',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
@@ -123,7 +241,7 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,description:string}>
|
||||
* @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,string>}>}>
|
||||
*/
|
||||
public function rolePresets(): array
|
||||
{
|
||||
@@ -133,11 +251,45 @@ class limited_backoffice_service
|
||||
'key' => $key,
|
||||
'label' => $preset['label'],
|
||||
'description' => $preset['description'],
|
||||
'permission_groups' => $this->rolePermissionGroups($preset['permissions']),
|
||||
];
|
||||
}
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
private function rolePermissionGroups(array $permissions): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$capability = self::ROLE_PERMISSION_CAPABILITIES[$permission] ?? null;
|
||||
if ($capability === null) {
|
||||
throw new \RuntimeException('Missing limited backoffice role capability for permission: ' . $permission);
|
||||
}
|
||||
|
||||
$group = $capability['group'];
|
||||
$groups[$group] ??= [];
|
||||
$groups[$group][] = $capability['capability'];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach (self::ROLE_PERMISSION_GROUP_ORDER as $group) {
|
||||
if (!isset($groups[$group])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($groups[$group])),
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\customer_order_product_policy;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
@@ -93,6 +94,7 @@ class order_items_o extends db
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Avoid SQL injection
|
||||
$reference = $db->escape_string($reference);
|
||||
$notes = $db->escape_string($notes);
|
||||
@@ -167,6 +169,7 @@ class order_items_o extends db
|
||||
try {
|
||||
// Get the order
|
||||
$order = (new orders_o())->getOrderById($order_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Get the product price
|
||||
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
||||
|
||||
@@ -354,4 +357,4 @@ class order_items_o extends db
|
||||
{
|
||||
return (new products_o())->select((int)$this->product_id->value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +348,54 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
->assertSuccess();
|
||||
|
||||
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
|
||||
$rolesByKey = array_column($roles->data(), null, 'key');
|
||||
expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([
|
||||
[
|
||||
'key' => 'account',
|
||||
'capabilities' => ['sign_in', 'view_own_permissions'],
|
||||
],
|
||||
]);
|
||||
$departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key');
|
||||
expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([
|
||||
'open_limited_backoffice',
|
||||
'manage_department_prices',
|
||||
'manage_employee_access',
|
||||
]);
|
||||
expect($roles->body)->not->toContain('department_access_');
|
||||
$rolePayload = $roles->data();
|
||||
$rolePayloadStrings = [];
|
||||
array_walk_recursive($rolePayload, static function ($value) use (&$rolePayloadStrings): void {
|
||||
if (is_string($value)) {
|
||||
$rolePayloadStrings[] = $value;
|
||||
}
|
||||
});
|
||||
foreach ([
|
||||
'list_orders',
|
||||
'add_order',
|
||||
'edit_order',
|
||||
'delete_order',
|
||||
'list_order_items',
|
||||
'add_order_items',
|
||||
'edit_order_items',
|
||||
'delete_order_items',
|
||||
'charge_order',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'department_timebookings_entries_get',
|
||||
'department_timebookings_entries_post',
|
||||
'department_timebookings_entries_put',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
'limited_backoffice_access',
|
||||
'limited_backoffice_prices_manage',
|
||||
'limited_backoffice_employees_manage',
|
||||
] as $rawPermission) {
|
||||
expect($rolePayloadStrings)->not->toContain($rawPermission);
|
||||
}
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Cashier',
|
||||
|
||||
@@ -15,7 +15,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
'reference' => 'NOTE-REQUIRED',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902701,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
@@ -49,6 +48,85 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
||||
});
|
||||
|
||||
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer_rules');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'ONLY-TANK',
|
||||
]);
|
||||
$washProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Forvogn',
|
||||
'price' => 649,
|
||||
'category' => 4,
|
||||
]);
|
||||
$tankCleaningProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'price' => 299,
|
||||
'category' => 5,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
api_client()
|
||||
->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $washProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_order_product_policy::ONLY_TANKCLEANING_MESSAGE);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $tankCleaningProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
|
||||
});
|
||||
|
||||
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer_rules');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'REGULAR-WASH',
|
||||
]);
|
||||
$washProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Forvogn',
|
||||
'price' => 649,
|
||||
'category' => 4,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $washProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
|
||||
});
|
||||
|
||||
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
||||
api_test_covers('PUT /order/items', 'validation');
|
||||
|
||||
@@ -62,7 +140,6 @@ it('does not allow clearing notes for order items whose product requires notes',
|
||||
'reference' => 'NOTE-EDIT',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902702,
|
||||
'name' => 'API Note Required Product',
|
||||
'price' => 199,
|
||||
'requires_note' => 1,
|
||||
@@ -75,7 +152,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', [
|
||||
@@ -95,7 +172,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
api_test_covers('GET /products', 'happy');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902703,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\customer_order_product_policy;
|
||||
|
||||
it('recognizes tankcleaning products by category and legacy names', function (): void {
|
||||
expect(customer_order_product_policy::isTankCleaningProductRow([
|
||||
'product_category' => 5,
|
||||
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'category_name' => 'Other',
|
||||
]))->toBeTrue()
|
||||
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||
'product_category' => 3,
|
||||
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||
'category_name' => 'Other',
|
||||
]))->toBeTrue()
|
||||
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||
'product_category' => 3,
|
||||
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'category_name' => 'Tankrens',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects only tankcleaning violations only for attributed customers and non-tank products', function (): void {
|
||||
$washProduct = [
|
||||
'product_category' => 4,
|
||||
'product_name' => 'Forvogn',
|
||||
'category_name' => 'Udvendig',
|
||||
];
|
||||
$tankCleaningProduct = [
|
||||
'product_category' => 5,
|
||||
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||
'category_name' => 'Tank cleaning',
|
||||
];
|
||||
|
||||
expect(customer_order_product_policy::onlyTankCleaningViolation(true, $washProduct))->toBeTrue()
|
||||
->and(customer_order_product_policy::onlyTankCleaningViolation(true, $tankCleaningProduct))->toBeFalse()
|
||||
->and(customer_order_product_policy::onlyTankCleaningViolation(false, $washProduct))->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user