Remove legacy booking completion forms and related logic
- Deleted `complete_booking_f` and `generate_booking_wash_certificate_f` classes. - Updated tests to ensure legacy booking completion routes are disabled. - Introduced tests for POST `/order-bookings/complete` to enforce POS-based booking completion management. - Added `/collected-invoices/split-by-month` route with API and unit tests for splitting collections into monthly periods. - Refactored impacted files to exclude legacy references and ensure continued compatibility with POS processes.
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace forms\objects;
|
||||
|
||||
use forms\form_helper_c;
|
||||
use objects\bookings_o;
|
||||
use traits\form_t;
|
||||
|
||||
class complete_booking_f extends form_helper_c
|
||||
{
|
||||
use form_t;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function sanitize(): void
|
||||
{
|
||||
foreach ( $this->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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace forms\objects;
|
||||
|
||||
use forms\form_helper_c;
|
||||
use objects\bookings_o;
|
||||
use traits\form_t;
|
||||
|
||||
class generate_booking_wash_certificate_f extends form_helper_c
|
||||
{
|
||||
use form_t;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function sanitize(): void
|
||||
{
|
||||
foreach ( $this->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' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
exit;
|
||||
|
||||
@@ -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<string,mixed>
|
||||
* @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<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function previewSplitByOrderMonth(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
$this->requireCanSplitByOrderMonth();
|
||||
|
||||
return $this->buildSplitByOrderMonthPreview($this->getIncludedOrdersGroupedByCreatedMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,orders_o[]> $orders_by_month
|
||||
* @return array<string,mixed>
|
||||
* @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<string,orders_o[]>
|
||||
* @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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function monthly_split_order_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 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);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('rejects standalone order booking completion outside POS', function (): void {
|
||||
$customer = api_fixtures()->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.');
|
||||
});
|
||||
@@ -612,6 +612,50 @@ final class ApiFixtures
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
it('unregisters legacy booking completion forms', function (): void {
|
||||
$formFile = app_path('classes/form.php');
|
||||
$code = (string)file_get_contents($formFile);
|
||||
|
||||
expect(is_file(app_path('modules/forms/objects/complete_booking_f.php')))->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.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user