Add bulk action preview and apply endpoints for collected invoices

This commit is contained in:
Jeppe Bundgaard
2026-07-08 13:46:31 +02:00
parent b7a2dc04d7
commit 6a694f92cc
6 changed files with 1087 additions and 5 deletions
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
usesApiSuite();
function bulk_action_order_item_deleted_at(int $orderItemId): ?string
{
$row = api_test_runtime()->queryOne('SELECT deleted_at FROM order_items WHERE id = ' . $orderItemId . ' LIMIT 1');
return $row['deleted_at'] ?? null;
}
function bulk_action_order_invoice_collection_id(int $orderId): int
{
$row = api_test_runtime()->queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
return (int)($row['invoice_collection_id'] ?? 0);
}
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$product = api_fixtures()->createProduct([
'name' => 'Spot Free rinse',
'price' => 80,
]);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 80,
]);
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'locale' => 'da',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['preview_id'] ?? null)->toBeString()
->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft')
->and($preview['summary']['changed_count'] ?? null)->toBe(1)
->and($preview['order_items'][0]['order_item_id'] ?? null)->toBe((int)$orderItem['id'])
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekraeft',
'locale' => 'da',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
expect(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekræft',
'locale' => 'da',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$applied = $applyResponse->data();
expect($applied['preview'] ?? null)->toBeFalse()
->and($applied['result']['changed_count'] ?? null)->toBe(1)
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull();
});
it('merges selected invoice collections into the explicit target after confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'merge');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'merge');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Merge Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$sourceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$targetOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
]);
$sourceOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['confirmation_phrase'] ?? null)->toBe('Confirm')
->and($preview['target_invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
->and($preview['summary']['orders_to_move'] ?? null)->toBe(1)
->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id']);
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id']);
});
@@ -24,6 +24,38 @@ it('builds stable XL Vask automation item signatures', function (): void {
]);
});
it('identifies strict price agreement matches by product, quantity, and total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
['product_id' => 21, 'quantity' => 1, 'price' => 79],
];
$orderItems = [
['product_id' => 21, 'quantity' => 1, 'price' => 79],
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeTrue();
});
it('rejects price agreement automation when product lines differ despite equal total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 50, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::itemsTotalForAutomation($usageItems))
->toBe(xlvask_automation_service::itemsTotalForAutomation($orderItems))
->and(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeFalse();
});
it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void {
$row = xlvask_automation_service::normalizeUsageLogRowForAutomation([
'id' => 47086,
@@ -107,6 +139,23 @@ it('declares cached amount summary columns for XL Vask usage logs', function ():
->toContain('cached_amount_at');
});
it('keeps automatic XL Vask execution scoped to exact attachments', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('&& $this->isExactAttachSuggestionForContext($suggestion, $context)')
->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;")
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
});
it('does not automatically create XL Vask orders', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('if ($action === self::ACTION_CREATE) {')
->toContain('return false;');
});
it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],