Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 76dddad410 Block restricted customer order items 2026-07-06 12:01:00 +02:00
9 changed files with 342 additions and 248 deletions
@@ -0,0 +1,158 @@
<?php
namespace classes;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
/**
* @return array{rule:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
$order = (new orders_o())->getOrderById($orderId);
if (!$order->exists()) {
return null;
}
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
return null;
}
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
if (!$customer->exists()) {
return null;
}
$categoryId = (int)$product->category->value();
$categoryName = $this->categoryName($categoryId);
$searchableProduct = $this->searchableProductText($product, $categoryName);
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
return $this->violation('restrictAdditionalServices');
}
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
return $this->violation('restrictTankCleaning');
}
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
return $this->violation('onlyTankCleaning');
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
return $this->violation('restrictSpotFree');
}
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
return $this->violation('restrictInteriorCleaning');
}
return null;
}
/**
* @return array{rule:string,message:string}
*/
private function violation(string $rule): array
{
return [
'rule' => $rule,
'message' => self::BLOCK_MESSAGE,
];
}
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
{
if ($relatedItemId !== null && $relatedItemId > 0) {
return true;
}
if ($categoryId === self::ADDON_CATEGORY_ID) {
return true;
}
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
return true;
}
return $this->countStandaloneOrderItems($orderId) > 0;
}
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
{
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
return true;
}
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
}
/**
* @param array<int, string> $terms
*/
private function containsAny(string $value, array $terms): bool
{
foreach ($terms as $term) {
if ($term !== '' && str_contains($value, $term)) {
return true;
}
}
return false;
}
private function categoryName(int $categoryId): string
{
global $db;
if ($categoryId <= 0) {
return '';
}
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
if (!$result || $result->num_rows === 0) {
return '';
}
$row = $result->fetch_assoc();
return strtolower((string)($row['name'] ?? ''));
}
private function countStandaloneOrderItems(int $orderId): int
{
global $db;
$result = $db->query(
'SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = ' . $orderId . '
AND deleted_at IS NULL
AND (related_item_id IS NULL OR related_item_id = 0)'
);
if (!$result) {
return 0;
}
$row = $result->fetch_assoc();
return (int)($row['item_count'] ?? 0);
}
}
@@ -240,13 +240,7 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
$result = $collected_order_invoices->asArray();
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
if ($transfer_metrics !== null) {
$result['economic_transfer_metrics'] = $transfer_metrics;
}
return $result;
return $collected_order_invoices->asArray();
}
/**
@@ -61,47 +61,19 @@ class economic_invoices_draft_endpoint
* @throws Exception If the request fails
*/
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
{
$this->add_orders($invoiceDraftId, [$order], $currency);
}
/**
* Add many orders to a draft invoice and flush their lines in batches.
*
* @param orders_o[] $orders
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
{
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
$orders_with_invoice_lines = 0;
foreach ( $orders as $order ) {
if (!$order instanceof orders_o) {
throw new Exception('Order payload must contain orders_o instances');
}
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() <= 0) {
continue;
}
$orders_with_invoice_lines++;
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() > 0) {
// Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order);
// Add the order lines
$draftInvoice->addOrderItemLines($order);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
// Save the draft invoice lines
$draftInvoice->addLines();
}
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
return [
'order_count' => count($orders),
'orders_with_invoice_lines' => $orders_with_invoice_lines,
...$metrics,
];
}
/**
@@ -179,4 +151,4 @@ class economic_invoices_draft_endpoint
$draft_invoice->addLines();
}
}
}
}
@@ -10,8 +10,6 @@ use objects\orders_o;
class economic_invoice_draft
{
public const DEFAULT_LINE_BATCH_SIZE = 500;
/**
* The Economic draftInvoiceNumber
* @var int $draft_invoice_number
@@ -112,55 +110,13 @@ class economic_invoice_draft
}
/**
* Add the lines to the draft invoice.
* Add the lines to the draft invoice
* @return void
*/
public function addLines(): void
{
$this->flushLinesInBatches();
}
/**
* Add queued draft lines using chunked requests.
*
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
*/
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
{
$lines = array_values($this->draft_lines);
$line_count = count($lines);
if ($line_count === 0) {
return [
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
];
}
$batch_size = max(1, $batch_size);
$batch_sizes = [];
foreach (array_chunk($lines, $batch_size) as $batch) {
$this->sendDraftLines($batch);
$batch_sizes[] = count($batch);
}
$this->draft_lines = [];
return [
'line_count' => $line_count,
'batch_count' => count($batch_sizes),
'batch_sizes' => $batch_sizes,
];
}
public function pendingLineCount(): int
{
return count($this->draft_lines);
}
protected function sendDraftLines(array $draft_lines): object
{
$economic = new economic();
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
}
/**
@@ -37,7 +37,6 @@ class collected_order_invoices_o extends db
public object_property $updated_at;
public object_property $closed_at;
public int $economic_wash_subscription_user_id = 1857;
private ?array $last_economic_transfer_metrics = null;
/**
* The processor types
*
@@ -713,7 +712,6 @@ class collected_order_invoices_o extends db
*/
public function addInvoicesToDraft(bool $skip_check = false): self
{
$this->last_economic_transfer_metrics = null;
// Require the invoice collection to be selected
self::requireSelected();
// Require the invoice collection to be open
@@ -738,20 +736,10 @@ class collected_order_invoices_o extends db
usort($orders, function ($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
// Add the invoice lines to the draft in one accumulated batch path.
$order_objects = [];
// Add the invoices to the invoice draft
foreach ( $orders as $order ) {
$order_object = new orders_o();
$order_object->select((int)$order['id']);
$order_object->requireSelected();
$order_objects[] = $order_object;
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
}
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
...$metrics,
];
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
// Object changed
@@ -760,11 +748,6 @@ class collected_order_invoices_o extends db
return $this;
}
public function getLastEconomicTransferMetrics(): ?array
{
return $this->last_economic_transfer_metrics;
}
/**
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
* @param int $draft_id The invoice draft id
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\customer_product_rule_service;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
@@ -70,6 +71,10 @@ class orderItemsRoute
$price = (int)self::getParameter('price');
}
}
$order = (new orders_o())->getOrderById((int)$data['order_id']);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
@@ -77,6 +82,19 @@ class orderItemsRoute
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
$customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) {
(new logs_o())->add(
'order_items',
'global',
1,
$user->id,
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
);
$response->error($customerRuleViolation['message'], 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o());
@@ -4,6 +4,38 @@ declare(strict_types=1);
usesApiSuite();
function create_order_item_rule_fixture(array $customerAttributes = []): array
{
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']);
foreach ($customerAttributes as $attribute) {
api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute);
}
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'RULE-CHECK',
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
return [
'customer' => $customer,
'department' => $department,
'order' => $order,
'session' => $session,
];
}
function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse
{
return api_client()->post('/order/items', array_merge([
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $overrides), $headers);
}
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
api_test_covers('POST /order/items', 'validation');
@@ -111,3 +143,126 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
expect($response->data()['requires_note'] ?? null)->toBeTrue();
});
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Drying add-on',
'category' => 4,
'price' => 50,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture();
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary unrestricted truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Unrestricted add-on',
'category' => 4,
'price' => 50,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('blocks related addon order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary related truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Related extra brush',
'price' => 35,
]);
$primaryItem = api_fixtures()->createOrderItem([
'order_id' => $fixture['order']['id'],
'product_id' => $primaryProduct['id'],
'cashier_id' => $cashier['id'],
'price' => 200,
]);
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'related_item_id' => $primaryItem['id'],
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture([$attribute]);
$product = api_fixtures()->createProduct($productAttributes);
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
})->with([
'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]],
'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]],
'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]],
]);
it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['onlyTankCleaning']);
$nonTankProduct = api_fixtures()->createProduct([
'name' => 'Exterior truck wash',
'price' => 180,
]);
$tankProduct = api_fixtures()->createProduct([
'name' => 'Tank cleaning',
'category' => 5,
'price' => 300,
]);
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
@@ -1,50 +0,0 @@
<?php
it('routes collected invoice draft line uploads through the multi-order batch endpoint', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/objects/collected_order_invoices_o.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
$start = strpos($content, 'public function addInvoicesToDraft');
$end = strpos($content, 'public function getLastEconomicTransferMetrics');
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
expect($end)->toBeGreaterThan($start);
$methodBlock = substr($content, (int)$start, (int)$end - (int)$start);
expect($methodBlock)->toContain('$order_objects = [];')
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);')
->and($methodBlock)->toContain('...$metrics')
->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);');
});
it('keeps single-order draft uploads as a wrapper around the batch endpoint', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
$singleStart = strpos($content, 'public function add_order');
$singleEnd = strpos($content, 'public function add_orders');
expect($singleStart)->not->toBeFalse();
expect($singleEnd)->not->toBeFalse();
expect($singleEnd)->toBeGreaterThan($singleStart);
$singleBlock = substr($content, (int)$singleStart, (int)$singleEnd - (int)$singleStart);
expect($singleBlock)->toContain('$this->add_orders($invoiceDraftId, [$order], $currency);');
$batchBlock = substr($content, (int)$singleEnd);
expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);')
->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines");
});
it('includes collected invoice batch transfer metrics in queue results when available', function (): void {
$content = file_get_contents(dirname(__DIR__, 3) . '/classes/economic_transfer_executor.php');
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();')
->and($content)->toContain("\$result['economic_transfer_metrics'] = \$transfer_metrics;");
});
@@ -1,92 +0,0 @@
<?php
use helpers\economic_invoice_draft;
if (!class_exists('EconomicInvoiceDraftBatchingProbe')) {
class EconomicInvoiceDraftBatchingProbe extends economic_invoice_draft
{
public array $sentBatches = [];
protected function sendDraftLines(array $draft_lines): object
{
$this->sentBatches[] = $draft_lines;
return (object)['lines' => $draft_lines];
}
}
}
if (!class_exists('EconomicInvoiceDraftFailingBatchingProbe')) {
class EconomicInvoiceDraftFailingBatchingProbe extends EconomicInvoiceDraftBatchingProbe
{
public int $failOnBatch = 1;
protected function sendDraftLines(array $draft_lines): object
{
if (count($this->sentBatches) + 1 === $this->failOnBatch) {
throw new RuntimeException('Simulated e-conomic line batch failure');
}
return parent::sendDraftLines($draft_lines);
}
}
}
it('does not call e-conomic when flushing an empty draft line buffer', function (): void {
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
$metrics = $draft->flushLinesInBatches();
expect($metrics)->toBe([
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
])->and($draft->sentBatches)->toBe([]);
});
it('flushes a small draft line buffer in one request and clears pending lines', function (): void {
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
$draft->addTextLine('line-0');
$draft->addTextLine('line-1');
$draft->addTextLine('line-2');
$metrics = $draft->flushLinesInBatches(500);
expect($metrics)->toBe([
'line_count' => 3,
'batch_count' => 1,
'batch_sizes' => [3],
])->and($draft->sentBatches)->toHaveCount(1)
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
->and($draft->sentBatches[0][2]['description'])->toBe('line-2')
->and($draft->pendingLineCount())->toBe(0);
});
it('chunks large draft line buffers while preserving line order', function (): void {
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
for ($i = 0; $i < 1201; $i++) {
$draft->addTextLine('line-' . $i);
}
$metrics = $draft->flushLinesInBatches(500);
expect($metrics)->toBe([
'line_count' => 1201,
'batch_count' => 3,
'batch_sizes' => [500, 500, 201],
])->and($draft->sentBatches)->toHaveCount(3)
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
->and($draft->sentBatches[1][0]['description'])->toBe('line-500')
->and($draft->sentBatches[2][200]['description'])->toBe('line-1200')
->and($draft->pendingLineCount())->toBe(0);
});
it('bubbles line batch failures and keeps pending lines available', function (): void {
$draft = new EconomicInvoiceDraftFailingBatchingProbe(123, 'DKK', true);
$draft->addTextLine('line-0');
expect(fn () => $draft->flushLinesInBatches(500))
->toThrow(RuntimeException::class, 'Simulated e-conomic line batch failure');
expect($draft->sentBatches)->toBe([])
->and($draft->pendingLineCount())->toBe(1);
});