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_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" ); $collectionId = (int)$result->fetch_assoc()['id']; $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'], ]); $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, ]); $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('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(); }); 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']); }); 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.'); }); 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); });