Implement move collected invoice to customer functionality with API endpoint and associated tests
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -25,6 +25,9 @@ class cors_policy
|
||||
'https://localhost:4433',
|
||||
'https://twdev.jeppeb.dk',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
|
||||
@@ -606,6 +606,76 @@ class collected_order_invoices_o extends db
|
||||
self::deleteCached('asArray', $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move this invoice collection and all attached orders to another customer.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function moveToCustomer(int $target_customer_number): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
self::requireSelected();
|
||||
self::requireValidCustomer((string)$target_customer_number);
|
||||
|
||||
if ($target_customer_number <= 0) {
|
||||
throw new Exception('Target customer number must be greater than zero');
|
||||
}
|
||||
|
||||
if (!empty($this->external_id->value()) || $this->booked_invoice_id->value() !== null) {
|
||||
throw new Exception('Invoice collections with an external or booked invoice cannot be moved');
|
||||
}
|
||||
|
||||
$source_customer_number = (int)$this->customer_number->value();
|
||||
if ($source_customer_number === $target_customer_number) {
|
||||
return [
|
||||
'invoice_collection_id' => (int)$this->id,
|
||||
'source_customer_number' => $source_customer_number,
|
||||
'target_customer_number' => $target_customer_number,
|
||||
'moved_order_ids' => [],
|
||||
'moved_order_count' => 0,
|
||||
'changed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$invoice_collection_id = (int)$this->id;
|
||||
$result = $db->query("SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}");
|
||||
$order_ids = array_map(
|
||||
static fn(array $row): int => (int)$row['id'],
|
||||
$db->fetch_all($result)
|
||||
);
|
||||
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
$this->customer_number->set($target_customer_number);
|
||||
|
||||
foreach ( $order_ids as $order_id ) {
|
||||
$order = (new orders_o())->select($order_id);
|
||||
if (!$order->exists()) {
|
||||
continue;
|
||||
}
|
||||
$order->customer_id->set($target_customer_number);
|
||||
$order->objectChanged();
|
||||
}
|
||||
|
||||
$this->objectChanged();
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->conn()->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'invoice_collection_id' => $invoice_collection_id,
|
||||
'source_customer_number' => $source_customer_number,
|
||||
'target_customer_number' => $target_customer_number,
|
||||
'moved_order_ids' => $order_ids,
|
||||
'moved_order_count' => count($order_ids),
|
||||
'changed' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the external id of the invoice collection
|
||||
* @throws Exception If the request was not successful
|
||||
|
||||
@@ -496,6 +496,49 @@ class orderInvoicesRoute
|
||||
]
|
||||
);
|
||||
|
||||
/** Collected order invoices > Move to customer > POST */
|
||||
$this->post('/collected-invoices/move-to-customer', function () {
|
||||
global $response;
|
||||
self::requirePermission('move_collected_invoice_customer');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'MOVE_COLLECTED_INVOICE_CUSTOMER', 'User tried to move a collected order invoice without a valid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
self::requireParameters(['id', 'customer_number']);
|
||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
||||
self::requireType((int)self::getParameter('customer_number'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
||||
self::requireMinValue((int)self::getParameter('customer_number'), 1);
|
||||
self::requireMaxValue((int)self::getParameter('customer_number'), 999999999);
|
||||
|
||||
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
|
||||
$collected_order_invoices->requireSelected();
|
||||
|
||||
try {
|
||||
$move_result = $collected_order_invoices->moveToCustomer((int)self::getParameter('customer_number'));
|
||||
} catch (\Throwable $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
}
|
||||
|
||||
(new logs_o())->add(
|
||||
'orderInvoices',
|
||||
'global',
|
||||
1,
|
||||
$user->id,
|
||||
'MOVE_COLLECTED_INVOICE_CUSTOMER',
|
||||
'User moved collected order invoice #' . (int)$collected_order_invoices->id . ' to customer #' . (int)self::getParameter('customer_number')
|
||||
);
|
||||
|
||||
$response->add_meta('move', $move_result);
|
||||
$response->success($collected_order_invoices->asArray());
|
||||
},
|
||||
[
|
||||
'move_collected_invoice_customer' => 'Move a collected order invoice and its orders to another customer. This is a superuser-only route.'
|
||||
]
|
||||
);
|
||||
|
||||
/** Collected order invoices > Split > POST */
|
||||
$this->post('/collected-invoices/split', function () {
|
||||
global $response;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function moved_invoice_collection_customer_number(int $invoiceCollectionId): int
|
||||
{
|
||||
$row = api_test_runtime()->queryOne('SELECT customer_number FROM collected_order_invoices WHERE id = ' . $invoiceCollectionId . ' LIMIT 1');
|
||||
return (int)($row['customer_number'] ?? 0);
|
||||
}
|
||||
|
||||
function moved_order_customer_number(int $orderId): int
|
||||
{
|
||||
$row = api_test_runtime()->queryOne('SELECT customer_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
|
||||
return (int)($row['customer_id'] ?? 0);
|
||||
}
|
||||
|
||||
it('moves a collected invoice collection and all attached orders to another customer', function (): void {
|
||||
api_test_covers('POST /collected-invoices/move-to-customer', 'happy');
|
||||
|
||||
$sourceCustomer = api_fixtures()->createUser(['display_name' => 'Move Source Customer']);
|
||||
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Move Target Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$invoiceCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $sourceCustomer['customer_number'],
|
||||
]);
|
||||
$firstOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $sourceCustomer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $invoiceCollection['id'],
|
||||
]);
|
||||
$secondOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $sourceCustomer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $invoiceCollection['id'],
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['move_collected_invoice_customer']);
|
||||
|
||||
$response = api_client()->post('/collected-invoices/move-to-customer', [
|
||||
'id' => $invoiceCollection['id'],
|
||||
'customer_number' => $targetCustomer['customer_number'],
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $response->data();
|
||||
$moveMeta = $response->meta()['move'] ?? [];
|
||||
|
||||
expect($payload['id'] ?? null)->toBe((int)$invoiceCollection['id'])
|
||||
->and($payload['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number'])
|
||||
->and($moveMeta['source_customer_number'] ?? null)->toBe((int)$sourceCustomer['customer_number'])
|
||||
->and($moveMeta['target_customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number'])
|
||||
->and($moveMeta['moved_order_count'] ?? null)->toBe(2)
|
||||
->and(moved_invoice_collection_customer_number((int)$invoiceCollection['id']))->toBe((int)$targetCustomer['customer_number'])
|
||||
->and(moved_order_customer_number((int)$firstOrder['id']))->toBe((int)$targetCustomer['customer_number'])
|
||||
->and(moved_order_customer_number((int)$secondOrder['id']))->toBe((int)$targetCustomer['customer_number']);
|
||||
});
|
||||
|
||||
it('rejects moving a collection that already has an external invoice reference', function (): void {
|
||||
api_test_covers('POST /collected-invoices/move-to-customer', 'external-guard');
|
||||
|
||||
$sourceCustomer = api_fixtures()->createUser(['display_name' => 'Move External Source Customer']);
|
||||
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Move External Target Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$invoiceCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $sourceCustomer['customer_number'],
|
||||
'external_id' => 'external-invoice-123',
|
||||
]);
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $sourceCustomer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $invoiceCollection['id'],
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['move_collected_invoice_customer']);
|
||||
|
||||
$response = api_client()->post('/collected-invoices/move-to-customer', [
|
||||
'id' => $invoiceCollection['id'],
|
||||
'customer_number' => $targetCustomer['customer_number'],
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
expect($response->data()['message'] ?? '')->toContain('external or booked invoice')
|
||||
->and(moved_invoice_collection_customer_number((int)$invoiceCollection['id']))->toBe((int)$sourceCustomer['customer_number'])
|
||||
->and(moved_order_customer_number((int)$order['id']))->toBe((int)$sourceCustomer['customer_number']);
|
||||
});
|
||||
@@ -34,6 +34,14 @@ it('builds credential-safe normal CORS response headers for allowed origins', fu
|
||||
expect($headers['Vary'])->toBe('Origin');
|
||||
});
|
||||
|
||||
it('allows the fallback Vite localhost dev origin used after port 5173 is busy', function (): void {
|
||||
$preflight = cors_policy::preflightResponse('http://localhost:5174', 'https://truckwash.io');
|
||||
|
||||
expect($preflight['allowed'])->toBeTrue();
|
||||
expect($preflight['status'])->toBe(200);
|
||||
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('http://localhost:5174');
|
||||
});
|
||||
|
||||
it('builds preflight CORS response headers for api-v2 release URLs', function (): void {
|
||||
$preflight = cors_policy::preflightResponse(
|
||||
'https://api-v2.truckwash.io/master/api',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
it('wires collected invoice customer moves through the dedicated route and permission', function (): void {
|
||||
$routeContent = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||
$objectContent = file_get_contents(app_path('objects/collected_order_invoices_o.php'));
|
||||
|
||||
expect($routeContent)->not->toBeFalse()
|
||||
->and($routeContent)->toContain("\$this->post('/collected-invoices/move-to-customer'")
|
||||
->and($routeContent)->toContain("self::requirePermission('move_collected_invoice_customer')")
|
||||
->and($routeContent)->toContain('$collected_order_invoices->moveToCustomer')
|
||||
->and($routeContent)->toContain("\$response->add_meta('move', \$move_result)")
|
||||
->and($objectContent)->not->toBeFalse()
|
||||
->and($objectContent)->toContain('public function moveToCustomer(int $target_customer_number): array')
|
||||
->and($objectContent)->toContain('Invoice collections with an external or booked invoice cannot be moved')
|
||||
->and($objectContent)->toContain('SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}')
|
||||
->and($objectContent)->toContain('$order->customer_id->set($target_customer_number)')
|
||||
->and($objectContent)->toContain('$this->customer_number->set($target_customer_number)')
|
||||
->and($objectContent)->toContain('$db->conn()->begin_transaction()');
|
||||
});
|
||||
Reference in New Issue
Block a user