Files
api/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php
T
Jeppe B 0b304a2203 Gate invoice tree activation on audit schema (#339)
## Summary
- gate object-tree v2 on exact, schema-backed supersession audit columns
- serialize checked additive DDL and fail closed without disrupting
legacy invoicing
- preserve pre-schema supersession markers when structured columns are
still null
- add a superuser-only, self-scoped canary endpoint with locked
legacy-to-canonical allowlist migration
- verify effective activation, roll back failed readiness, and audit
enable/disable/failure

## Verification
- focused invoicing safety: 16 tests passed (99 assertions)
- full unit suite: 1,237 passed (9,003 assertions), 2 skipped, existing
warnings only
- PHP syntax and `git diff --check` clean
- independent architecture, security, and reviewer gates: GO

## Activation
Deploy with global database/environment enablement off. POST the
self-canary endpoint for one authenticated superuser, require
`configured_enabled=true` and `effective_enabled=true`, then verify the
exact period and tree GET routes. Roll back with the same endpoint using
`enabled=false`.
2026-08-03 13:03:55 +02:00

700 lines
31 KiB
PHP

<?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);
}
function bulk_action_invoice_collection_row(int $invoiceCollectionId): array
{
$supersessionColumns = api_test_runtime()->queryOne(
"SELECT COUNT(*) AS column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'collected_order_invoices'
AND COLUMN_NAME IN ('superseded_by_collection_id', 'superseded_by_user_id', 'superseded_at')"
);
$select = 'name, notes, closed_at';
if ((int)($supersessionColumns['column_count'] ?? 0) === 3) {
$select .= ', superseded_by_collection_id, superseded_by_user_id, superseded_at';
}
return api_test_runtime()->queryOne(
'SELECT ' . $select . ' FROM collected_order_invoices WHERE id = '
. $invoiceCollectionId . ' LIMIT 1'
) ?? [];
}
function bulk_action_configure_rule_product(string $attribute, int $productId): void
{
new \classes\customer_rule_product_restriction_service();
$db = api_test_runtime()->db();
$safeAttribute = $db->real_escape_string($attribute);
$result = $db->query(
"SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
);
$row = $result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
$db->query(
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
VALUES ('{$safeAttribute}', 1)"
);
$db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', 'Invoice tree API fixture', 0)"
);
$collectionId = (int)$db->insert_id;
} else {
$collectionId = (int)$row['id'];
}
expect($collectionId)->toBeGreaterThan(0);
$db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, {$productId})"
);
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
'collection_id' => $collectionId,
'product_id' => $productId,
]);
}
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'],
]);
$outsidePeriodOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
'created_at' => '2025-01-15 12:00:00',
]);
$product = api_fixtures()->createProduct([
'name' => 'Spot Free rinse',
'price' => 80,
]);
bulk_action_configure_rule_product('restrictSpotFree', (int)$product['id']);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 80,
]);
$regularProduct = api_fixtures()->createProduct([
'name' => 'Allowed related product',
'price' => 10,
]);
$childItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $regularProduct['id'],
'cashier_id' => 1,
'price' => 10,
'related_item_id' => $orderItem['id'],
'include_in_invoice' => 0,
]);
$grandchildItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $regularProduct['id'],
'cashier_id' => 1,
'price' => 5,
'related_item_id' => $childItem['id'],
'include_in_invoice' => 0,
]);
$unrelatedHiddenItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $regularProduct['id'],
'cashier_id' => 1,
'price' => 7,
'include_in_invoice' => 0,
]);
$outsidePeriodViolation = api_fixtures()->createOrderItem([
'order_id' => $outsidePeriodOrder['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();
$previewItemIds = array_map('intval', array_column($preview['order_items'] ?? [], 'order_item_id'));
sort($previewItemIds);
$expectedCleanupIds = array_map('intval', [
$orderItem['id'],
$childItem['id'],
$grandchildItem['id'],
$outsidePeriodViolation['id'],
]);
sort($expectedCleanupIds);
expect($preview['preview_id'] ?? null)->toBeString()
->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft')
->and($preview['summary']['changed_count'] ?? null)->toBe(4)
->and($previewItemIds)->toBe($expectedCleanupIds)
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$childItem['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$grandchildItem['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$outsidePeriodViolation['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$unrelatedHiddenItem['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(4)
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$childItem['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$grandchildItem['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$outsidePeriodViolation['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$unrelatedHiddenItem['id']))->toBeNull();
})->group('invoice-tree-v2');
it('binds tree previews to complete off-period impact and rejects foreign assigned orders', function (): void {
api_test_covers('POST /superuser/invoicing/period/tree-actions/preview', 'snapshot-bound-off-period-impact');
$customer = api_fixtures()->createUser(['display_name' => 'Bound Tree Preview Customer']);
$foreignCustomer = api_fixtures()->createUser(['display_name' => 'Foreign Bound Tree Customer']);
$department = api_fixtures()->createDepartment();
$collection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$periodOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $collection['id'],
'created_at' => '2026-07-15 12:00:00',
]);
$outsideOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $collection['id'],
'created_at' => '2026-06-15 12:00:00',
]);
$product = api_fixtures()->createProduct(['name' => 'Bound Spot Free', 'price' => 60]);
bulk_action_configure_rule_product('restrictSpotFree', (int)$product['id']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
api_fixtures()->createOrderItem([
'order_id' => $periodOrder['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 60,
]);
api_fixtures()->createOrderItem([
'order_id' => $outsideOrder['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 60,
]);
api_fixtures()->setModuleConfig('InvoicingPeriod', 'object_tree_v2_enabled', 'true');
$session = api_fixtures()->createUserSession([
'superuser_invoicing_period',
'reset_collected_invoice_economic',
]);
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$hadRedisConfig = array_key_exists('REDIS_CONFIG', $GLOBALS);
$previousRedisConfig = $GLOBALS['REDIS_CONFIG'] ?? null;
$testDb = new \classes\db([
'host' => getenv('CONFIG_DB_HOST') ?: 'mysql-debug',
'user' => getenv('CONFIG_DB_USER') ?: 'root',
'password' => getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password',
'database' => getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug',
'port' => (int)(getenv('CONFIG_DB_PORT') ?: 3306),
]);
$testDb->connect();
$GLOBALS['db'] = $testDb;
$GLOBALS['REDIS_CONFIG'] = [
'host' => getenv('REDIS_CONFIG_HOST') ?: 'redis',
'port' => (int)(getenv('REDIS_CONFIG_PORT') ?: 6379),
'database' => (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0),
'user' => '',
'password' => '',
];
try {
$service = new \classes\invoice_collection_bulk_action_service();
$binding = $service->createSnapshotBinding(
(int)$session['user']['id'],
(int)$customer['customer_number'],
'2026-07-01',
'2026-07-31',
[(int)$collection['id']]
);
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
if ($hadRedisConfig) {
$GLOBALS['REDIS_CONFIG'] = $previousRedisConfig;
} else {
unset($GLOBALS['REDIS_CONFIG']);
}
}
$previewResponse = api_client()->post('/superuser/invoicing/period/tree-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [(int)$collection['id']],
'options' => [],
'locale' => 'en',
'customer_number' => (int)$customer['customer_number'],
'snapshot_revision' => (string)$binding['snapshot_revision'],
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['summary']['changed_count'] ?? null)->toBe(2)
->and($preview['off_period_impact']['order_count'] ?? null)->toBe(1)
->and($preview['off_period_impact']['total_net_amount'] ?? null)->toBe(60);
api_fixtures()->createOrder([
'customer_id' => $foreignCustomer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $collection['id'],
'created_at' => '2026-07-20 12:00:00',
]);
$GLOBALS['db'] = $testDb;
$GLOBALS['REDIS_CONFIG'] = [
'host' => getenv('REDIS_CONFIG_HOST') ?: 'redis',
'port' => (int)(getenv('REDIS_CONFIG_PORT') ?: 6379),
'database' => (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0),
'user' => '',
'password' => '',
];
try {
expect(fn() => $service->createSnapshotBinding(
(int)$session['user']['id'],
(int)$customer['customer_number'],
'2026-07-01',
'2026-07-31',
[(int)$collection['id']]
))->toThrow(\classes\invoice_collection_bulk_action_conflict::class);
} finally {
$testDb->close();
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
if ($hadRedisConfig) {
$GLOBALS['REDIS_CONFIG'] = $previousRedisConfig;
} else {
unset($GLOBALS['REDIS_CONFIG']);
}
}
})->group('invoice-tree-v2');
it('blocks collection mutations including monthly split while an e-conomic export job is active', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Queued Tree Mutation Customer']);
$collection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$db = api_test_runtime()->db();
$payload = $db->real_escape_string(json_encode([
'collected_invoice_id' => (int)$collection['id'],
], JSON_THROW_ON_ERROR));
$db->query(
"INSERT INTO economic_transfer_queue_jobs
(transfer_type, payload_json, status, progress_percent, progress_message, attempts, max_attempts, created_by)
VALUES ('COLLECTED_INVOICE_EXPORT', '{$payload}', 'QUEUED', 0, 'Queued', 0, 3, 1)"
);
$queueJobId = (int)$db->insert_id;
api_fixtures()->cleanupDeleteWhere('economic_transfer_queue_jobs', ['id' => $queueJobId]);
$session = api_fixtures()->createUserSession([
'reset_collected_invoice_economic',
'split_collected_invoice',
]);
foreach (['remove_customer_rule_violations', 'split_by_month'] as $action) {
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => $action,
'invoice_collection_ids' => [(int)$collection['id']],
'options' => [],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect(array_column($preview['blockers'] ?? [], 'code'))->toContain('collection_export_queued')
->and($preview['blockers'][0]['queue_job_id'] ?? null)->toBe($queueJobId);
if ($action === 'split_by_month') {
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => $action,
'invoice_collection_ids' => [(int)$collection['id']],
'options' => [],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invoice collection has an active e-conomic export job.');
}
}
})->group('invoice-tree-v2');
it('previews and applies customer rule cleanup for both spotfree addon products', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup-spotfree-addons');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup-spotfree-addons');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Spotfree Addons 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'],
]);
$spotfreeVanProduct = api_fixtures()->createProduct([
'id' => 23,
'name' => 'Skylning med RO - Varevogn',
'category' => 4,
'price' => 39,
]);
$spotfreeTruckProduct = api_fixtures()->createProduct([
'id' => 24,
'name' => 'Skylning med RO - Lastbil',
'category' => 4,
'price' => 39,
]);
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeVanProduct['id']);
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeTruckProduct['id']);
$vanOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeVanProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$truckOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeTruckProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$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' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
$previewOrderItemIds = array_map('intval', array_column($preview['order_items'] ?? [], 'order_item_id'));
sort($previewOrderItemIds);
expect($preview['summary']['changed_count'] ?? null)->toBe(2)
->and($previewOrderItemIds)->toBe([(int)$vanOrderItem['id'], (int)$truckOrderItem['id']])
->and(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['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' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->not->toBeNull();
})->group('invoice-tree-v2');
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'],
'name' => 'Authoritative target',
'notes' => 'Keep target metadata',
]);
$sourceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
'name' => 'Merge source',
'notes' => 'Keep source audit note',
]);
$targetOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
]);
$targetOutsidePeriodOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2025-01-10 12:00:00',
]);
$sourceOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
]);
$sourceOutsidePeriodOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
'created_at' => '2025-01-20 12:00:00',
'include_in_invoice' => 0,
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
$otherActorSession = 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(2)
->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$sourceOutsidePeriodOrder['id']))->toBe((int)$sourceCollection['id']);
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',
], $otherActorSession['headers'])
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$sourceOutsidePeriodOrder['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();
$applied = $applyResponse->data();
$targetRow = bulk_action_invoice_collection_row((int)$targetCollection['id']);
$sourceRow = bulk_action_invoice_collection_row((int)$sourceCollection['id']);
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$sourceOutsidePeriodOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$targetOutsidePeriodOrder['id']))->toBe((int)$targetCollection['id'])
->and($targetRow['name'] ?? null)->toBe('Authoritative target')
->and($targetRow['notes'] ?? null)->toBe('Keep target metadata')
->and($sourceRow['closed_at'] ?? null)->not->toBeNull()
->and($applied['result']['superseded_invoice_collection_ids'] ?? [])
->toBe([(int)$sourceCollection['id']]);
if (array_key_exists('superseded_by_collection_id', $sourceRow)) {
expect((int)$sourceRow['superseded_by_collection_id'])->toBe((int)$targetCollection['id'])
->and((int)($sourceRow['superseded_by_user_id'] ?? 0))->toBeGreaterThan(0)
->and($sourceRow['superseded_at'] ?? null)->not->toBeNull()
->and($sourceRow['notes'] ?? null)->toBe('Keep source audit note');
} else {
expect($sourceRow['notes'] ?? '')->toContain('[[invoice_collection_superseded:');
}
$exportSession = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
api_client()->post('/collected-invoices/economic', [
'id' => (int)$sourceCollection['id'],
], $exportSession['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invoice collection has been superseded and cannot be exported.');
$supersededMergePreview = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $sourceCollection['id']],
'locale' => 'en',
], $session['headers']);
$supersededMergePreview
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(array_column($supersededMergePreview->data()['blockers'] ?? [], 'code'))
->toContain('collection_superseded');
})->group('invoice-tree-v2');
it('rejects apply when export-relevant order item content changed after preview', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'stale-export-content');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'stale-export-content');
$customer = api_fixtures()->createUser(['display_name' => 'Stale Bulk Preview Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection(['customer_number' => $customer['customer_number']]);
$sourceCollection = api_fixtures()->createInvoiceCollection(['customer_number' => $customer['customer_number']]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
]);
$product = api_fixtures()->createProduct(['name' => 'Digest Product', 'price' => 100]);
$item = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 100,
'quantity' => 1,
]);
$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();
api_test_runtime()->db()->query('UPDATE order_items SET price = 125 WHERE id = ' . (int)$item['id']);
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'])
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('The invoice collections changed after preview. Refresh the preview before applying this action.');
})->group('invoice-tree-v2');
it('blocks queue economic preview and apply for the configured draft customer', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'draft-customer');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'draft-customer');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Bulk Draft Customer']);
$collection = api_fixtures()->createInvoiceCollection(['customer_number' => $draftCustomer['customer_number']]);
api_fixtures()->setModuleConfig(
'economic',
'transactionDraftCustomerNumber',
(string)$draftCustomer['customer_number'],
'int'
);
$session = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'queue_economic',
'invoice_collection_ids' => [$collection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse->assertStatus(200)->assertEnvelope()->assertSuccess();
$preview = $previewResponse->data();
expect($preview['blockers'][0] ?? null)->toMatchArray([
'code' => 'draft_customer_export_blocked',
'invoice_collection_id' => (int)$collection['id'],
'message' => \classes\economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE,
]);
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'queue_economic',
'invoice_collection_ids' => [$collection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE);
});