diff --git a/services/nginx/app/classes/form.php b/services/nginx/app/classes/form.php index 9a3a6132..e2c5ac9e 100644 --- a/services/nginx/app/classes/form.php +++ b/services/nginx/app/classes/form.php @@ -12,8 +12,6 @@ use Exception; use forms\form_helper_c; use forms\objects\book_interior_wash_f; use forms\objects\book_wash_f; -use forms\objects\complete_booking_f; -use forms\objects\generate_booking_wash_certificate_f; use objects\form_submissions_o; use traits\form_t; @@ -30,25 +28,12 @@ class form * @var book_wash_f $book_wash The BOOK_WASH form */ public book_wash_f $book_wash; - /** - * The GENERATE_BOOKING_CERTIFICATE form - * @var generate_booking_wash_certificate_f $generate_booking_wash_certificate The GENERATE_BOOKING_CERTIFICATE form - */ - public generate_booking_wash_certificate_f $generate_booking_wash_certificate; - - /** - * The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form - * @var complete_booking_f $complete_booking_without_wash_certificate The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form - */ - public complete_booking_f $complete_booking_without_wash_certificate; public $last_submitted_form; public form_submissions_o $form_submission; public function __construct() { $this->book_wash = new book_wash_f(); - $this->generate_booking_wash_certificate = new generate_booking_wash_certificate_f(); - $this->complete_booking_without_wash_certificate = new complete_booking_f(); } /** @@ -109,4 +94,4 @@ class form } throw new Exception('The form was not found'); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/forms/objects/complete_booking_f.php b/services/nginx/app/modules/forms/objects/complete_booking_f.php deleted file mode 100644 index daad4d94..00000000 --- a/services/nginx/app/modules/forms/objects/complete_booking_f.php +++ /dev/null @@ -1,103 +0,0 @@ -form_unsanitized_data as $key => $value ) { - // Sanitize the input TODO: Implement the sanitization logic - $this->form_unsanitized_data[$key] = $value; - } - // Set the sanitized data - $this->form_sanitized_data = $this->form_unsanitized_data; - } - - /** - * @inheritDoc - */ - public function validateInput(): void - { - - } - - /** - * @inheritDoc - */ - public function beforeSave(): void - { - // Get the booking from the booking id - $bookings = new bookings_o(); - $bookings->select(self::getSanitizedData('booking_id')); - // Check if the user has access to the booking - self::restrictAccessDepartment($bookings->department->value()); - // Check if the user has access to issue wash certificates - self::requirePermission( - 'issue_wash_certificates', - 'User does not have access to issue wash certificates', - ); - // Set the department id to the department id of the user - self::setDepartmentId($bookings->department->value()); - // Set the customer number to the customer number of the user - self::setCustomerNumber($bookings->customer_number->value()); - // Check if the booking is cancelled - switch ($bookings->status->value()) { - case 'cancelled': - throw new \Exception('Booking is cancelled'); - break; - case 'completed': - throw new \Exception('Booking is completed'); - break; - case 'pending': - break; - default: - throw new \Exception('Booking status is unknown, expected cancelled, completed or pending'); - break; - } - // Generate the wash certificate - $bookings->completeWashWithoutWashCertificate( - $bookings->id, - ); - } - - /** - * @inheritDoc - */ - public function afterSubmit(): void - { - // TODO: Implement afterSubmit() method. - } - - /** - * @inheritDoc - */ - public function setup(): void - { - self::setFormIdentifier('COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE'); - self::setFormName('Bekræft vask'); - self::setFormDescription('Bekræft vask af booking'); - self::setSubmitButtonText('Bekræft vask'); - // Set the form fields - self::defineInputFieldsAdvanced([ - 'booking_id' => [ - 'description' => 'Booking ID er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'required' => true, - 'placeholder' => 'Indtast booking ID', - 'label' => 'Booking ID', - 'help' => 'Dette er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'error' => 'Du skal indtaste et gyldigt booking ID.', - 'validation_method' => 'validateBookingId', - ], - ]); - } -} \ No newline at end of file diff --git a/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php b/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php deleted file mode 100644 index 1cb69f5b..00000000 --- a/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php +++ /dev/null @@ -1,142 +0,0 @@ -form_unsanitized_data as $key => $value ) { - // Sanitize the input TODO: Implement the sanitization logic - $this->form_unsanitized_data[$key] = $value; - } - // Set the sanitized data - $this->form_sanitized_data = $this->form_unsanitized_data; - } - - /** - * @inheritDoc - */ - public function validateInput(): void - { - - } - - /** - * @inheritDoc - */ - public function beforeSave(): void - { - // Get the booking from the booking id - $bookings = new bookings_o(); - $bookings->select(self::getSanitizedData('booking_id')); - // Check if the user has access to the booking - self::restrictAccessDepartment($bookings->department->value()); - // Check if the user has access to issue wash certificates - self::requirePermission( - 'issue_wash_certificates', - 'User does not have access to issue wash certificates', - ); - // Set the department id to the department id of the user - self::setDepartmentId($bookings->department->value()); - // Set the customer number to the customer number of the user - self::setCustomerNumber($bookings->customer_number->value()); - // Check if the booking is cancelled - switch ($bookings->status->value()) { - case 'cancelled': - throw new \Exception('Booking is cancelled'); - break; - case 'completed': - throw new \Exception('Booking is completed'); - break; - case 'pending': - break; - default: - throw new \Exception('Booking status is unknown, expected cancelled, completed or pending'); - break; - } - try { - $safety_seal = (int)self::getSanitizedData('safety_seal'); - } catch (\Exception $e) { - $safety_seal = null; - } - try { - $operator = (string)self::getSanitizedData('operator'); - } catch (\Exception $e) { - $operator = null; - } - // Generate the wash certificate - $bookings->generateWashCertificate( - $safety_seal, - $operator, - ); - // Send the wash certificate to the customer - $bookings->sendWashCertificateToCustomer(); - // Create the transaction based on the booking - //$transaction = $bookings->createTransaction(); - // Mark the booking as completed - $bookings->status->set('completed'); - $bookings->washCertificateStatus->set('completed'); - } - - /** - * @inheritDoc - */ - public function afterSubmit(): void - { - // TODO: Implement afterSubmit() method. - } - - /** - * @inheritDoc - */ - public function setup(): void - { - self::setFormIdentifier('GENERATE_BOOKING_WASH_CERTIFICATE'); - self::setFormName('Opret vaskecertifikat'); - self::setFormDescription('Du kan oprette et vaskecertifikat til en vask, der allerede er booket. Du skal blot indtaste de nødvendige oplysninger nedenfor.'); - self::setSubmitButtonText('Opret vaskecertifikat'); - // Set the form fields - self::defineInputFieldsAdvanced([ - 'booking_id' => [ - 'description' => 'Booking ID er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'required' => true, - 'placeholder' => 'Indtast booking ID', - 'label' => 'Booking ID', - 'help' => 'Dette er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'error' => 'Du skal indtaste et gyldigt booking ID.', - 'validation_method' => 'validateBookingId', - ], - 'safety_seal' => [ - 'description' => 'En sikkerhedssikring er en form for beskyttelse, der sikrer, at køretøjet ikke er blevet åbnet eller ændret efter vasken.', - 'required' => false, - 'placeholder' => 'Indtast sikkerhedssikring', - 'label' => 'Safety Seal / PLOM', - 'help' => 'Dette er et Safety Seal, der bruges til at dokumentere, at køretøjet er blevet vasket.', - 'error' => 'Du skal indtaste en gyldig sikkerhedssikring.', - 'validation_method' => 'validateInt', - 'default' => '', - ], - 'operator' => [ - 'description' => 'Operatøren er den person, der har vasket køretøjet. Dette felt er valgfrit.', - 'required' => false, - 'placeholder' => 'Indtast operatør', - 'label' => 'Vognvasker', - 'help' => 'Dette er navnet på den person, der har vasket køretøjet.', - 'error' => 'Du skal indtaste en gyldig operatør.', - 'validation_method' => 'validateString', - 'default' => '', - ], - ]); - - } -} \ No newline at end of file diff --git a/services/nginx/app/modules/washcertificates/index.php b/services/nginx/app/modules/washcertificates/index.php index 9563d96c..ea0f02fe 100644 --- a/services/nginx/app/modules/washcertificates/index.php +++ b/services/nginx/app/modules/washcertificates/index.php @@ -79,6 +79,10 @@ if (isset($_GET['justDownload'])) { exit; } +http_response_code(410); +echo 'Booking completion must be completed through POS desktop or mobile steps.'; +exit; + // Require the $_GET variables sealOrPlumber, safetySeal, performedBy, and bookingId, regNumber, and regNumberTrailer to be set if (!isset($_GET['sealOrPlumber']) || !isset($_GET['performedBy']) || !isset($_GET['bookingId']) || !isset($_GET['regNumber']) || !isset($_GET['regNumberTrailer']) || !isset($_GET['department'])) { // We are missing some required fields in the query string @@ -140,4 +144,4 @@ $booking->washCertificateStatus->set('completed'); // Return the generated certificate object download URL echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']); // Exit the script -exit; \ No newline at end of file +exit; diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 1411b6fe..4cf996c2 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -1032,6 +1032,207 @@ class collected_order_invoices_o extends db $this->objectChanged(); } + /** + * Split this invoice collection into one collection per order month. + * + * @return array + * @throws Exception + */ + public function splitByOrderMonth(): array + { + global $db; + + self::requireSelected(); + $this->requireCanSplitByOrderMonth(); + + $orders_by_month = $this->getIncludedOrdersGroupedByCreatedMonth(); + $preview = $this->buildSplitByOrderMonthPreview($orders_by_month); + if (($preview['status'] ?? '') === 'skipped') { + $preview['preview'] = false; + return $preview; + } + + $original_invoice_collection_id = (int)$this->id; + $created_invoice_collection_ids = []; + $month_collection_ids = []; + $months = array_keys($orders_by_month); + $month_results = $preview['months']; + + $db->conn()->begin_transaction(); + try { + foreach ( $months as $index => $month ) { + $month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01'); + $month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01'); + if ($index === 0) { + $month_collection = $this; + $month_collection->created_at->set($month_timestamp); + $month_collection->closed_at->set($month_closed_at); + } else { + $month_collection = (new collected_order_invoices_o())->add( + (int)$this->customer_number->value(), + $this->name->value(), + $this->notes->value(), + null, + $month_closed_at + ); + $month_collection->created_at->set($month_timestamp); + $created_invoice_collection_ids[] = (int)$month_collection->id; + } + + $month_collection_ids[$month] = (int)$month_collection->id; + $month_results[$index]['invoice_collection_id'] = (int)$month_collection->id; + $month_results[$index]['target_invoice_collection_id'] = (int)$month_collection->id; + } + + foreach ( $orders_by_month as $month => $orders ) { + $target_invoice_collection_id = (int)$month_collection_ids[$month]; + foreach ( $orders as $order ) { + if ((int)$order->invoice_collection_id->value() === $target_invoice_collection_id) { + continue; + } + $order->assignToInvoiceCollection($target_invoice_collection_id); + } + } + + $this->objectChanged(); + foreach ( $created_invoice_collection_ids as $created_invoice_collection_id ) { + (new collected_order_invoices_o())->select($created_invoice_collection_id)->objectChanged(); + } + + $db->conn()->commit(); + } catch (\Throwable $e) { + $db->conn()->rollback(); + throw $e; + } + + return [ + 'status' => 'changed', + 'invoice_collection_id' => $original_invoice_collection_id, + 'preview' => false, + 'created_invoice_collection_ids' => $created_invoice_collection_ids, + 'months' => $month_results, + ]; + } + + /** + * Preview how this invoice collection would be split into one collection per order month. + * + * @return array + * @throws Exception + */ + public function previewSplitByOrderMonth(): array + { + self::requireSelected(); + $this->requireCanSplitByOrderMonth(); + + return $this->buildSplitByOrderMonthPreview($this->getIncludedOrdersGroupedByCreatedMonth()); + } + + /** + * @param array $orders_by_month + * @return array + * @throws Exception + */ + private function buildSplitByOrderMonthPreview(array $orders_by_month): array + { + if (empty($orders_by_month)) { + throw new Exception('No orders in invoice collection'); + } + + ksort($orders_by_month); + if (count($orders_by_month) < 2) { + return [ + 'status' => 'skipped', + 'reason' => 'already_single_month', + 'message' => 'Invoice collection already belongs to one month', + 'invoice_collection_id' => (int)$this->id, + 'preview' => true, + 'months' => array_keys($orders_by_month), + ]; + } + + $months = []; + foreach ( array_keys($orders_by_month) as $index => $month ) { + $order_ids = array_map(static function (orders_o $order): int { + return (int)$order->id; + }, $orders_by_month[$month]); + $month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01'); + $month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01'); + $will_create_collection = $index !== 0; + $months[] = [ + 'month' => $month, + 'invoice_collection_id' => $will_create_collection ? null : (int)$this->id, + 'target_invoice_collection_id' => $will_create_collection ? null : (int)$this->id, + 'source_invoice_collection_id' => (int)$this->id, + 'will_create_collection' => $will_create_collection, + 'order_count' => count($orders_by_month[$month]), + 'order_ids' => $order_ids, + 'created_at' => $month_timestamp, + 'closed_at' => $month_closed_at, + ]; + } + + return [ + 'status' => 'changed', + 'invoice_collection_id' => (int)$this->id, + 'preview' => true, + 'created_invoice_collection_ids' => [], + 'months' => $months, + ]; + } + + /** + * @throws Exception + */ + private function requireCanSplitByOrderMonth(): void + { + self::requireSelected(); + self::requireInvoiceIsNotBooked(); + + $processor = $this->processor->value(); + $processor = $processor === null ? 0 : (int)$processor; + if ($processor === STRIPE_PROCESSOR) { + throw new Exception('Stripe invoice collections cannot be split'); + } + if ($processor === OTHER_PROCESSOR) { + throw new Exception('Due to the stateless nature of the processor, invoice collections cannot be split. Please contact technical support for assistance.'); + } + if (!in_array($processor, [0, ECONOMIC_PROCESSOR], true)) { + throw new Exception('Invalid processor type'); + } + if (!empty($this->external_id->value())) { + throw new Exception('Invoice collection already has an external invoice reference'); + } + } + + /** + * @return array + * @throws Exception + */ + private function getIncludedOrdersGroupedByCreatedMonth(): array + { + $order_ids = self::getOrderIds(); + $orders_by_month = []; + foreach ( $order_ids as $order_id ) { + $order = (new orders_o())->select((int)$order_id['id']); + $order->requireSelected(); + if ($order->isBooked(true)) { + throw new Exception('Invoice collection contains booked orders'); + } + + $created_at = (string)$order->created_at->value(); + if (strtotime($created_at) === false) { + throw new Exception('Order has invalid created_at date'); + } + + $month = date('Y-m', strtotime($created_at)); + $orders_by_month[$month] = $orders_by_month[$month] ?? []; + $orders_by_month[$month][] = $order; + } + + return $orders_by_month; + } + /** * Add the vehicle subscriptions transaction to the invoice collection * @throws Exception If the invoice collection is not selected @@ -1254,6 +1455,18 @@ class collected_order_invoices_o extends db return $date->format('Y-m-d H:i:s'); } + private static function getLastSecondOfMonthIfEnded(string $timestamp): ?string + { + $date = new \DateTime($timestamp); + $date->modify('last day of this month'); + $date->setTime(23, 59, 59); + if ($date > new \DateTime()) { + return null; + } + + return $date->format('Y-m-d H:i:s'); + } + /** * Get the wash subscription price * @param float $price The price of the wash subscription diff --git a/services/nginx/app/routes/bookingsRoute.php b/services/nginx/app/routes/bookingsRoute.php index d036b0d3..01c79042 100644 --- a/services/nginx/app/routes/bookingsRoute.php +++ b/services/nginx/app/routes/bookingsRoute.php @@ -468,6 +468,7 @@ class bookingsRoute // Require the user to be logged in global /** @var response $response */ $response; + $response->error('Booking completion must be completed through POS desktop or mobile steps.', 410); $this->requirePermission('complete_wash_without_wash_certificate'); // Get the user object $user = (new authentication())->get_user(); diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index c7b672cc..fafa4acc 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -381,6 +381,9 @@ class orderBookingRoute if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } + if (!(int)$object->order_id->value()) { + $response->error('Booking completion must be completed through POS desktop or mobile steps.', 409); + } /** * Complete the booking */ diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 7e71cbd5..6d744d0f 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary; use classes\economic_v2_compare_engine; use classes\economic_v2_line_normalizer; use classes\economic_v2_revenue_statistics_service; +use classes\invoicing_period_utils; use classes\response; use classes\router; use Exception; @@ -525,6 +526,123 @@ class orderInvoicesRoute ] ); + /** Collected order invoices > Split by month > POST */ + $this->post('/collected-invoices/split-by-month', function () { + global $response, $db; + self::requirePermission('split_collected_invoice'); + $user = (new authentication())->get_user(); + if ($user) { + self::requireParameters(['dateFrom', 'dateTo']); + + try { + $date_range = invoicing_period_utils::normalizeDateRange( + (string)self::getParameter('dateFrom'), + (string)self::getParameter('dateTo') + ); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + + $preview = false; + if (self::isParametersSet(['preview'])) { + $preview_raw = self::getParameter('preview'); + if (is_bool($preview_raw)) { + $preview = $preview_raw; + } elseif (is_numeric($preview_raw)) { + $preview = ((int)$preview_raw) === 1; + } elseif (is_string($preview_raw)) { + $normalized_preview = strtolower(trim($preview_raw)); + if (!in_array($normalized_preview, ['true', 'false', '1', '0'], true)) { + $response->error('preview must be a boolean', 400); + } + $preview = in_array($normalized_preview, ['true', '1'], true); + } else { + $response->error('preview must be a boolean', 400); + } + } + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + $user->id, + $preview ? 'PREVIEW_SPLIT_COLLECTED_INVOICE_BY_MONTH' : 'SPLIT_COLLECTED_INVOICE_BY_MONTH', + $preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month' + ); + + $date_from = $db->escape_string($date_range['dateFrom']); + $date_to = $db->escape_string($date_range['dateTo']); + $sql = "SELECT DISTINCT invoice_collection_id + FROM orders + WHERE created_at BETWEEN '$date_from' AND '$date_to' + AND invoice_collection_id IS NOT NULL + AND invoice_collection_id > 0 + AND deleted_at IS NULL"; + $query_result = $db->query($sql); + $invoice_collection_ids = []; + while ($row = $query_result->fetch_assoc()) { + $invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0); + if ($invoice_collection_id > 0) { + $invoice_collection_ids[] = $invoice_collection_id; + } + } + + $items = []; + $changed = []; + $skipped = []; + foreach ( array_values(array_unique($invoice_collection_ids)) as $invoice_collection_id ) { + try { + $collected_order_invoices = (new collected_order_invoices_o())->select($invoice_collection_id); + $collected_order_invoices->requireSelected(); + $split_result = $preview + ? $collected_order_invoices->previewSplitByOrderMonth() + : $collected_order_invoices->splitByOrderMonth(); + $item = [ + 'invoice_collection_id' => $invoice_collection_id, + ...$split_result, + ]; + if (($split_result['status'] ?? '') === 'changed') { + $changed[] = $item; + } else { + $skipped[] = $item; + } + $items[] = $item; + } catch (\Throwable $e) { + $item = [ + 'status' => 'skipped', + 'invoice_collection_id' => $invoice_collection_id, + 'preview' => $preview, + 'reason' => 'not_splittable', + 'message' => $e->getMessage(), + ]; + $skipped[] = $item; + $items[] = $item; + } + } + + $response->success([ + 'message' => $preview + ? 'Collected invoice monthly split preview completed' + : 'Collected invoice monthly split completed', + 'preview' => $preview, + 'dateFrom' => $date_range['dateFrom'], + 'dateTo' => $date_range['dateTo'], + 'processed_count' => count($items), + 'changed_count' => count($changed), + 'skipped_count' => count($skipped), + 'changed' => $changed, + 'skipped' => $skipped, + 'items' => $items, + ]); + } else { + (new logs_o())->add('orderInvoices', 'global', 0, 0, 'SPLIT_COLLECTED_INVOICE_BY_MONTH', 'User tried to split collected order invoices by month without a valid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'split_collected_invoice' => 'Split collected order invoices by order month. This is a superuser-only route.' + ] + ); + /** Collected order invoices > E-Conomic > POST (queued) */ $this->post('/collected-invoices/economic', function () { global $response; diff --git a/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php new file mode 100644 index 00000000..336129dc --- /dev/null +++ b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php @@ -0,0 +1,347 @@ +queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1'); + return (int)($row['invoice_collection_id'] ?? 0); +} + +function monthly_split_invoice_row(int $invoiceCollectionId): array +{ + return api_test_runtime()->queryOne('SELECT id, created_at, closed_at FROM collected_order_invoices WHERE id = ' . $invoiceCollectionId . ' LIMIT 1') ?? []; +} + +function monthly_split_customer_collection_count(int $customerNumber): int +{ + $row = api_test_runtime()->queryOne('SELECT COUNT(*) AS count FROM collected_order_invoices WHERE customer_number = ' . $customerNumber); + return (int)($row['count'] ?? 0); +} + +function monthly_split_cleanup_collections(array $invoiceCollectionIds): void +{ + $ids = array_values(array_unique(array_filter(array_map('intval', $invoiceCollectionIds), static fn(int $id): bool => $id > 0))); + if ($ids === []) { + return; + } + + api_test_runtime()->db()->query('UPDATE orders SET invoice_collection_id = NULL WHERE invoice_collection_id IN (' . implode(',', $ids) . ')'); + api_test_runtime()->db()->query('DELETE FROM collected_order_invoices WHERE id IN (' . implode(',', $ids) . ')'); +} + +it('previews monthly split changes without moving orders or creating collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'preview'); + + $customer = api_fixtures()->createUser(['display_name' => 'Preview Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $collectionCountBefore = monthly_split_customer_collection_count((int)$customer['customer_number']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + 'preview' => true, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $months = (array)($payload['changed'][0]['months'] ?? []); + expect($payload['preview'] ?? null)->toBeTrue() + ->and($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($payload['changed'][0]['created_invoice_collection_ids'] ?? null)->toBe([]) + ->and($months[0]['month'] ?? null)->toBe('2096-03') + ->and($months[0]['will_create_collection'] ?? null)->toBeFalse() + ->and($months[0]['target_invoice_collection_id'] ?? null)->toBe((int)$invoiceCollection['id']) + ->and($months[0]['closed_at'] ?? null)->toBeNull() + ->and($months[1]['month'] ?? null)->toBe('2096-04') + ->and($months[1]['will_create_collection'] ?? null)->toBeTrue() + ->and($months[1]['target_invoice_collection_id'] ?? null)->toBeNull() + ->and($months[1]['closed_at'] ?? null)->toBeNull() + ->and(monthly_split_customer_collection_count((int)$customer['customer_number']))->toBe($collectionCountBefore) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); +}); + +it('splits a selected March and April collected invoice into monthly collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'happy'); + + $customer = api_fixtures()->createUser(['display_name' => 'Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2096-03-01 00:00:01', + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($payload['skipped_count'] ?? null)->toBe(0) + ->and($aprilCollectionId)->toBeGreaterThan(0) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['created_at'] ?? null)->toBe('2096-03-01 00:00:01') + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBeNull() + ->and(monthly_split_invoice_row($aprilCollectionId)['created_at'] ?? null)->toBe('2096-04-01 00:00:01') + ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBeNull(); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('sets closed_at to month end when split month has ended', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'closed-at'); + + $customer = api_fixtures()->createUser(['display_name' => 'Ended Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2001-03-01 00:00:01', + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2001-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2001-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2001-03-01', + 'dateTo' => '2001-04-30', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['changed_count'] ?? null)->toBe(1) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBe('2001-03-31 23:59:59') + ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBe('2001-04-30 23:59:59'); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('splits the whole affected collection even when only one month is selected', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'partial'); + + $customer = api_fixtures()->createUser(['display_name' => 'Partial Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-20 09:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-10 09:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['changed_count'] ?? null)->toBe(1) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('does not affect booked collected invoices', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'booked-skip'); + + $customer = api_fixtures()->createUser(['display_name' => 'Booked Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'booked_invoice_id' => 987654, + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-05 12:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-05 12:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['changed_count'] ?? null)->toBe(0) + ->and($payload['skipped_count'] ?? null)->toBe(1) + ->and((string)($payload['skipped'][0]['message'] ?? ''))->toContain('booked') + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); +}); + +it('skips draft linked, Stripe, and single month collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'skip'); + + $customer = api_fixtures()->createUser(['display_name' => 'Skipped Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $draftCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'external_id' => 'draft-external-reference', + ]); + $stripeCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'processor' => 2, + ]); + $singleMonthCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + + foreach ([$draftCollection, $stripeCollection] as $collection) { + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $collection['id'], + 'created_at' => '2096-03-05 12:00:00', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $collection['id'], + 'created_at' => '2096-04-05 12:00:00', + ]); + } + + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $singleMonthCollection['id'], + 'created_at' => '2096-03-10 12:00:00', + ]); + + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['processed_count'] ?? null)->toBe(3) + ->and($payload['changed_count'] ?? null)->toBe(0) + ->and($payload['skipped_count'] ?? null)->toBe(3); +}); + +it('rejects invalid monthly split date ranges', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'failure'); + + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-04-30', + 'dateTo' => '2096-03-01', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); +}); diff --git a/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php b/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php new file mode 100644 index 00000000..8b64cf61 --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php @@ -0,0 +1,98 @@ +createUser(['display_name' => 'Standalone Booking Customer']); + $department = api_fixtures()->createDepartment(); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => null, + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + ], $session['headers']); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Booking completion must be completed through POS desktop or mobile steps.'); + + $result = api_test_runtime()->db() + ->query('SELECT order_id FROM order_bookings WHERE id = ' . (int)$booking['id'] . ' LIMIT 1'); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect($row['order_id'] ?? null)->toBeNull(); + + $result = api_test_runtime()->db() + ->query('SELECT COUNT(*) AS total FROM orders WHERE customer_id = ' . (int)$customer['customer_number']); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect((int)($row['total'] ?? -1))->toBe(0); +}); + +it('allows linked POS order booking completion for mobile POS compatibility', function (): void { + $customer = api_fixtures()->createUser(['display_name' => 'Linked Booking Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Linked Booking Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'safety_seal' => null, + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + 'safety_seal' => 123456, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['id'] ?? null)->toBe($booking['id']); + expect($response->data()['order_id'] ?? null)->toBe($order['id']); + + $result = api_test_runtime()->db() + ->query('SELECT order_id FROM order_bookings WHERE id = ' . (int)$booking['id'] . ' LIMIT 1'); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect((int)($row['order_id'] ?? 0))->toBe($order['id']); +}); + +it('disables the legacy complete wash without certificate route', function (): void { + $response = api_client()->post('/admin/bookings/completeWashWithoutWashCertificate', [ + 'id' => 123, + ]); + + $response + ->assertStatus(410) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Booking completion must be completed through POS desktop or mobile steps.'); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 741179d7..bf95af75 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -612,6 +612,50 @@ final class ApiFixtures ]; } + /** + * @param array $attributes + * @return array + */ + public function createOrderBooking(array $attributes): array + { + $customerNumber = (int)($attributes['customer_number'] ?? 0); + $departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0); + if ($customerNumber <= 0 || $departmentId <= 0) { + throw new RuntimeException('Order bookings require customer_number and department.'); + } + + $bookingId = $this->insertRow('order_bookings', [ + 'customer_number' => $customerNumber, + 'department' => $departmentId, + 'reg_1' => (string)($attributes['reg_1'] ?? 'BOOK123'), + 'reg_2' => (string)($attributes['reg_2'] ?? ''), + 'reg_3' => (string)($attributes['reg_3'] ?? ''), + 'datetime' => $attributes['datetime'] ?? $this->now(), + 'note' => (string)($attributes['note'] ?? ''), + 'reference' => (string)($attributes['reference'] ?? 'API-BOOKING'), + 'po' => (string)($attributes['po'] ?? ''), + 'pickup' => (int)($attributes['pickup'] ?? 0), + 'items' => $attributes['items'] ?? [], + 'order_id' => $attributes['order_id'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(function () use ($bookingId): void { + $this->deleteById('order_bookings', $bookingId); + $this->deleteRedisKey('order_bookings_' . $bookingId . '_asArray'); + $this->deleteRedisPattern('order_bookings:*'); + }); + + return [ + 'id' => $bookingId, + 'customer_number' => $customerNumber, + 'department' => $departmentId, + 'order_id' => $attributes['order_id'] ?? null, + ]; + } + /** * @param array $attributes * @return array diff --git a/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php b/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php new file mode 100644 index 00000000..8f4b9a64 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php @@ -0,0 +1,29 @@ +toBeFalse(); + expect(is_file(app_path('modules/forms/objects/generate_booking_wash_certificate_f.php')))->toBeFalse(); + expect($code)->not->toContain('complete_booking_f'); + expect($code)->not->toContain('generate_booking_wash_certificate_f'); + expect($code)->not->toContain('COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE'); + expect($code)->not->toContain('GENERATE_BOOKING_WASH_CERTIFICATE'); +}); + +it('keeps legacy wash certificate downloads but disables generation and completion', function (): void { + $code = (string)file_get_contents(app_path('modules/washcertificates/index.php')); + + $downloadPosition = strpos($code, "isset(\$_GET['justDownload'])"); + $disabledPosition = strpos($code, 'http_response_code(410)'); + $completionPosition = strpos($code, "\$booking->status->set('completed')"); + + expect($downloadPosition)->not->toBeFalse(); + expect($disabledPosition)->not->toBeFalse(); + expect($completionPosition)->not->toBeFalse(); + expect($downloadPosition)->toBeLessThan($disabledPosition); + expect($disabledPosition)->toBeLessThan($completionPosition); + expect($code)->toContain('Booking completion must be completed through POS desktop or mobile steps.'); +}); +