post('/order-bookings', function () { // Require the user to be logged in global $response; /** * Parameters */ $customer_number = self::getTargetCustomer(); // users_o object $department = self::getTargetDepartment(); // Int $reg_1 = self::getTargetReg(1); // String $reg_2 = self::getTargetReg(2); // String | Null $reg_3 = self::getTargetReg(3); // String | Null $datetime = self::getTargetDateTime(); // DateTime $note = self::getTargetNote(); // String | Null $reference = self::getTargetReference(); // String | Null $po = self::getTargetPo(); // String | Null $pickup = self::getTargetPickup(); // Bool | Null $this->requireOrderBookingCreateAccess( (int)$customer_number->customer_number->value(), (int)$department->id ); $items = self::getTargetItems( true, $customer_number, (int)$department->id ); // Array of order_items_o objects $this->requireBookingItemsAllowedForCustomer($customer_number, $items); /** * Input data */ $data = [ 'customer_number' => (int)$customer_number->customer_number->value(), 'department' => $department->id, 'reg_1' => $reg_1, 'reg_2' => $reg_2, 'reg_3' => $reg_3, 'datetime' => $datetime->format('Y-m-d H:i:s'), 'note' => $note, 'reference' => $reference, 'po' => $po, 'pickup' => $pickup, 'items' => $items, ]; $fingerprint = $this->buildBookingCreationFingerprint($data); $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint); if ($cachedBooking !== null) { $response->success($cachedBooking->asArray()); } if (!$this->reserveBookingCreationSlot($fingerprint)) { $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint); if ($cachedBooking !== null) { $response->success($cachedBooking->asArray()); } $response->error('A similar booking request is already being processed. Please wait a moment and retry.', 429); } /** * Create the object */ $order_bookings_o = new order_bookings_o(); try { $order_bookings_o->add($data); $this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id); (new security_policy_service())->observeBookingCreated((int)$customer_number->customer_number->value(), [ 'booking_id' => (int)$order_bookings_o->id, 'route' => '/order-bookings', ]); } catch (\Throwable $e) { $this->clearBookingCreationSlot($fingerprint); throw $e; } $response->success($order_bookings_o->asArray()); }, [ 'add_bookings' => 'Permission to create order bookings for another customer or department scope.', 'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.' ] ); $this->get('/order-bookings', function () { // Require the user to be logged in global $response; /** * Parameters */ $object = self::getTargetObject(); /** Authentication */ $auth = new authentication(); $user = $auth->get_user(); /** * Permissions */ $permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST); $permission_other = self::definePermission('list_bookings'); $has_permission_other = self::hasPermission($permission_other); $targetCustomer = !empty($object) ? (int)$object->customer_number->value() : null; $deptId = !empty($object) ? (int)$object->department->value() : null; self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, $targetCustomer, $deptId, null, 'You do not have permission to view this order booking.' ); /** * Return the object */ if (!empty($object)) $response->success($object->asArray()); /** * Return the filtered list of objects */ $object = new order_bookings_o(); // New object for listing $effectiveCustomer = self::resolveEffectiveCustomerNumber(); $forcedFilters = $object->forceRestrictFilters([ ...($has_permission_other && $user !== false ? [ 'department' => $this->effectiveDepartmentIds($user) ] : []), ...(!$has_permission_other && $effectiveCustomer !== null ? [ 'customer_number' => [(int)$effectiveCustomer] ] : []) ]); $cacheTtl = order_bookings_list_cache::getTtl(); $cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null; if ($cacheKey !== null) { $cachedPayload = order_bookings_list_cache::getPayload($cacheKey); if ($cachedPayload !== null) { $response->rawJson($cachedPayload); } } $result = $object->listObjectsWithPaginationIfSet( function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); }, $forcedFilters ); $payload = [ 'success' => true, 'data' => $result, 'meta' => $response->get_meta(), 'includes' => $response->get_includes(), ]; if ($cacheKey !== null) { order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl); } $response->rawJson($payload); }, [ 'list_own_bookings' => 'Permission to list own order bookings.', 'list_bookings' => 'Permission to list department order bookings.', ] ); $this->get('/order-bookings/counts', function () { global $response; $auth = new authentication(); $user = $auth->get_user(); $permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST); $permission_other = self::definePermission('list_bookings'); $hasPermissionOwn = self::hasPermission($permission_own); $hasPermissionOther = self::hasPermission($permission_other); if (!$hasPermissionOwn && !$hasPermissionOther) { $this->emitForbidden([$permission_own, $permission_other]); } $requestedDepartmentId = $this->getTargetCountDepartmentId(); if ($hasPermissionOther && $requestedDepartmentId !== null) { self::requireDepartmentAccess((string)$requestedDepartmentId); } $effectiveCustomer = null; if (!$hasPermissionOther) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || $effectiveCustomer < 1) { $this->emitForbidden([$permission_other]); } } $departmentIds = null; if ($requestedDepartmentId !== null) { $departmentIds = [$requestedDepartmentId]; } elseif ($hasPermissionOther && $user !== false) { $departmentIds = $this->effectiveDepartmentIds($user); if ($departmentIds === []) { $response->success([ 'past' => 0, 'current' => 0, 'future' => 0, ]); } } $cacheTtl = order_bookings_counts_cache::getTtl(); $cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsCountsCacheKey($departmentIds, $effectiveCustomer) : null; if ($cacheKey !== null) { $cachedCounts = order_bookings_counts_cache::getCounts($cacheKey); if ($cachedCounts !== null) { $response->success($cachedCounts); } } $counts = (new order_bookings_o())->getPendingBookingCounts( $departmentIds, $effectiveCustomer ); if ($cacheKey !== null) { order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl); } $response->success($counts); }, [ 'list_own_bookings' => 'Permission to list own order booking counts.', 'list_bookings' => 'Permission to list department order booking counts.', ] ); $this->put('/order-bookings', function () { // Require the user to be logged in global $response; /** * Parameters */ $object = self::getTargetObject(); $customer_number = self::getTargetCustomer(false); // users_o object (Can only be changed by admins) $department = self::getTargetDepartment(false); // Int $reg_1 = self::getTargetReg(1, false); // String $reg_2 = self::getTargetReg(2, false); // String | Null $reg_3 = self::getTargetReg(3, false); // String | Null $datetime = self::getTargetDateTime(false); // DateTime $note = self::getTargetNote(false); // String | Null $reference = self::getTargetReference(false); // String | Null $po = self::getTargetPo(false); // String | Null $pickup = self::getTargetPickup(false); // Bool | Null $order_id_was_set = self::isParametersSet(['order_id']); $order_id = self::getTargetOrderId(false); // Int | Null /** Authentication */ $auth = new authentication(); $user = $auth->get_user(); /** * Permissions */ if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } $permission_own = self::definePermission('edit_own_bookings', subusers_permission_node_key::BOOKINGS_EDIT); $permission_other = self::definePermission('edit_bookings'); $has_permission_other = self::hasPermission($permission_other); $ownGuard = function () use ($has_permission_other, $object) { // Block own edits when a transaction exists, unless admin/department permission is present return $has_permission_other || !$object->hasTransaction(); }; self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, (int)$object->customer_number->value(), (int)$object->department->value(), $ownGuard, 'You do not have permission to edit this order booking.' ); $items = self::getTargetItems( false, $customer_number ?: (new users_o())->getUserByCustomerNumber((int)$object->customer_number->value()), $department !== null ? (int)$department->id : (int)$object->department->value() ); // Array of order_items_o objects /** * Update the object */ $previous_order_id = (int)($object->order_id->value() ?? 0); $data = [ ...(self::hasPermission($permission_other) && !empty($customer_number) ? [ 'customer_number' => (int)$customer_number->customer_number->value(), ] : []), ...(isset($department) ? ['department' => $department->id] : []), ...(isset($reg_1) ? ['reg_1' => $reg_1] : []), ...(isset($reg_2) ? ['reg_2' => $reg_2] : []), ...(isset($reg_3) ? ['reg_3' => $reg_3] : []), ...(isset($datetime) ? ['datetime' => $datetime->format('Y-m-d H:i:s')] : []), ...(isset($note) ? ['note' => $note] : []), ...(isset($reference) ? ['reference' => $reference] : []), ...(isset($po) ? ['po' => $po] : []), ...(isset($pickup) ? ['pickup' => $pickup] : []), ...(isset($items) ? ['items' => $items] : []), ...($order_id_was_set ? ['order_id' => $order_id] : []), ]; $object->update($data); if ($order_id_was_set && $order_id === null) { self::clearMatchingOrderBookingLink($object, $previous_order_id); } /** * Return the object */ $response->success($object->asArray()); }, [ 'edit_bookings' => 'Permission to edit order bookings. Only applies to bookings already permitted.', 'edit_own_bookings' => 'Permission to edit own order bookings.' ] ); $this->delete('/order-bookings', function () { // Require the user to be logged in global $response; /** * Parameters */ $object = self::getTargetObject(); /** Authentication */ $auth = new authentication(); $user = $auth->get_user(); /** * Permissions */ if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } $permission_own = self::definePermission('delete_own_bookings', subusers_permission_node_key::BOOKINGS_DELETE); $permission_other = self::definePermission('delete_bookings'); self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, (int)$object->customer_number->value(), (int)$object->department->value(), null, 'You do not have permission to delete this order booking.' ); /** * Delete the object */ $object->delete(); /** * Return success */ $response->success(['message' => 'Order booking deleted successfully.']); }, [ 'delete_bookings' => 'Permission to delete order bookings. Only applies to bookings already permitted.', 'delete_own_bookings' => 'Permission to delete own order bookings. Only applies to uncompleted bookings.' ] ); $this->post('/order-bookings/booking-confirmation/resend', function () { global $response; $object = self::getTargetObject(); if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } self::requirePermission('resend_booking_confirmations'); self::requireDepartmentAccess((int)$object->department->value()); (new email())->sendOrderBookingConfirmationEmail($object); $response->success([ 'message' => 'Booking confirmation resent successfully.', 'booking' => $object->asArray(), ]); }, [ 'resend_booking_confirmations' => 'Permission for department admins to resend order booking confirmation emails.' ] ); $this->post('/order-bookings/completion-confirmation/resend', function () { global $response; $object = self::getTargetObject(); if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } self::requirePermission('complete_bookings'); self::requireDepartmentAccess((int)$object->department->value()); if (!$object->hasTransaction()) { $response->error('Order booking has not been completed yet.', 409); } if (!$object->getOrder()->hasWashCertificateAttached()) { $response->error('Order booking completion confirmation is not available yet.', 409); } (new email())->sendWashCertificateEmailToCustomer($object); $response->success([ 'message' => 'Completion confirmation resent successfully.', 'booking' => $object->asArray(), ]); }, [ 'complete_bookings' => 'Permission for department admins to resend order booking completion confirmation emails.' ] ); $this->post('/order-bookings/complete', function () { // Require the user to be logged in global $response; /** * Parameters */ $object = self::getTargetObject(); $safetySeal = self::getSafetySeal(false); /** * Authentication */ $user = (new authentication())->get_user(); /** * Permissions */ self::requireDepartmentAccess((int)$object->department->value()); 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 */ $object->completeBooking((int)$user->id, $safetySeal); /** * Return the object */ $response->success($object->asArray()); }, [ 'complete_bookings' => 'Permission to complete order bookings.' ] ); } private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string { global $response; $page = (int)($response->getRequestParameter('page') ?? 0); if ($page < 1) { $page = 1; } $limit = (int)($response->getRequestParameter('limit') ?? 0); if ($limit < 1) { $limit = 1000; } return order_bookings_list_cache::buildKey([ 'route' => '/order-bookings', 'page' => $page, 'limit' => $limit, 'search' => (string)($response->getRequestParameter('search') ?? ''), 'order' => $this->normalizeOrderBookingsListCacheOrder((string)($response->getRequestParameter('order') ?? 'id:ASC')), 'filters' => $object->filter_string_to_array($forcedFilters), ]); } private function getOrderBookingsCountsCacheKey(?array $departmentIds, ?int $customerNumber): string { return order_bookings_counts_cache::buildKey([ 'route' => '/order-bookings/counts', 'day' => (new \DateTimeImmutable('now'))->format('Y-m-d'), 'department_ids' => $departmentIds ?? [], 'customer_number' => $customerNumber, ]); } private function normalizeOrderBookingsListCacheOrder(string $order): array { $normalized = trim($order); if ($normalized === '') { $normalized = 'id:ASC'; } $parts = array_pad(explode(':', $normalized, 2), 2, 'ASC'); $field = trim((string)$parts[0]); $direction = strtoupper(trim((string)$parts[1])); if ($field === '') { $field = 'id'; } if ($direction === '') { $direction = 'ASC'; } return [$field => $direction]; } private function getTargetCountDepartmentId(): ?int { global $response; $parameter = 'department'; $rawValue = $this->fromRequest($parameter); if ($rawValue === null || $rawValue === '') { return null; } if (!is_numeric($rawValue)) { $response->error('Invalid department', 400); } $value = (int)$rawValue; if ($value < 1 || $value > 999999999) { $response->error('Invalid department', 400); } return $value; } /** * @throws Exception */ private function getTargetObject(): order_bookings_o|null { global $response; $parameter = 'id'; $error = 'Invalid ' . $parameter; if (!$this->isParametersSet([$parameter])) return null; self::requireParameters(['id']); self::requireType((int)self::getParameter($parameter), self::type_int()); self::requireMinLength($parameter, 1); self::requireMaxLength($parameter, 9); $value = (int)self::getParameter($parameter); self::requireMinValue($value, 1); self::requireMaxValue($value, 999999999); $object = (new order_bookings_o())->select($value); if (!$object->exists()) $response->error($error, 400); return $object; } private function getSafetySeal(bool $required = true): int|null { global $response; $parameter = 'safety_seal'; $error = 'Invalid safety seal'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); $rawValue = self::getParameter($parameter); if (!$required && $rawValue === null) return null; if (is_string($rawValue)) { $rawValue = trim($rawValue); if (!$required && $rawValue === '') return null; if (!ctype_digit($rawValue)) $response->error($error, 400); } elseif (!is_int($rawValue)) { if ($required) { self::requireType($rawValue, self::type_int()); } else { self::requireTypeIn($rawValue, [self::type_int(), self::type_null()]); } } if ($required || $rawValue !== null) { $valueLength = strlen((string)$rawValue); if ($valueLength < 1) $response->error('Parameter ' . $parameter . ' must be at least 1 characters long', 400); if ($valueLength > 9) $response->error('Parameter ' . $parameter . ' must be at most 9 characters long', 400); } $value = (int)$rawValue; self::requireMinValue($value, 1); self::requireMaxValue($value, 999999999); return $value; } private function getTargetOrderId(bool $required = true): int|null { global $response; $parameter = 'order_id'; $error = 'Invalid order ID'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); if ($required) { self::requireType(self::getParameter($parameter), self::type_int()); } else { self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]); // Check if the value is null if ($this->getParameter($parameter) === null) return null; } self::requireMinLength($parameter, 1); self::requireMaxLength($parameter, 9); $value = (int)self::getParameter($parameter); self::requireMinValue($value, 1); self::requireMaxValue($value, 999999999); return $value; } /** * @throws Exception */ private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void { if ($previous_order_id <= 0) { return; } $previous_order = (new orders_o())->select($previous_order_id); if (!$previous_order->exists()) { return; } if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) { return; } $previous_order->booking_id->set(null); $previous_order->objectChanged(); } private function getTargetCustomer(bool $required = true): users_o|null { global $response; $parameter = 'customer_number'; $error = 'Invalid customer number'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireType(self::getParameter($parameter), self::type_int()); self::requireMinLength($parameter, 2); self::requireMaxLength($parameter, 9); $value = (int)self::getParameter($parameter); self::requireMinValue($value, 1); self::requireMaxValue($value, 999999999); $object = (new users_o())->getUserByCustomerNumber($value); if (!$object->exists()) $response->error($error, 400); // If a subuser is making the request, expose the target customer number in meta try { $auth = new authentication(); if ($auth->get_subuser() !== false) { $response->add_meta('target_customer_number', $value); } } catch (\Throwable $e) { /* ignore meta set errors */ } return $object; } private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void { $auth = new authentication(); if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) { $permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD); if (!self::hasPermission($permissionOwn, $targetCustomerNumber)) { $this->emitForbidden([$permissionOwn]); } return; } if ( $auth->get_user() !== false && self::hasPermission('user') && $this->isOwnCustomerContext($targetCustomerNumber) ) { return; } $permissionOther = self::definePermission('add_bookings'); if (!self::hasPermission($permissionOther)) { $this->emitForbidden([$permissionOther]); } self::requireDepartmentAccess((string)$departmentId); } private function isOrderBookingCustomerSession(): bool { try { $auth = new authentication(); if ($auth->get_subuser() !== false) { return true; } return $auth->get_user() !== false && self::hasPermission('user'); } catch (Exception) { return false; } } /** * @throws Exception If the Department is invalid. */ private function getTargetDepartment(bool $required = true): departments_o|null { global $response; $parameter = 'department'; $error = 'Invalid department'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireType(self::getParameter($parameter), self::type_int()); self::requireMinLength($parameter, 1); self::requireMaxLength($parameter, 9); $value = (int)self::getParameter($parameter); self::requireMinValue($value, 1); self::requireMaxValue($value, 999999999); $object = (new departments_o())->select($value); if (!$object->exists()) $response->error($error, 400); return $object; } private function getTargetReg(int $index = 1, bool $required = true): string|null { global $response; $parameter = 'reg_' . $index; $error = 'Invalid ' . $parameter; $requiredParam = $required && $index === 1; if (!$requiredParam && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); if ($requiredParam) { self::requireType(self::getParameter($parameter), self::type_string()); } else { self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]); // Check if the value is null if ($this->getParameter($parameter) === null) return "null"; } self::requireMinLength($parameter, 0); self::requireMaxLength($parameter, 12); $value = self::getParameter($parameter); return self::cleanReg($value); } private function getTargetDateTime(bool $required = true): \DateTime|null { global $response; $parameter = 'datetime'; $error = 'Invalid datetime'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireType(self::getParameter($parameter), self::type_string()); self::requireMinLength($parameter, 10); self::requireMaxLength($parameter, 25); $value = (string)self::getParameter($parameter); $fromFormat = strlen($value) === 10 ? 'Y-m-d' : 'Y-m-d H:i:s'; $datetime = \DateTime::createFromFormat($fromFormat, $value); if (!$datetime) { $response->error($error, 400); } return $datetime; } private function getTargetNote(bool $required = false): string|null { $parameter = 'note'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]); if (self::getParameter($parameter) === null) return null; self::requireMinLength($parameter, 0); self::requireMaxLength($parameter, 1000); return (string)self::getParameter($parameter); } private function getTargetReference(bool $required = false): string|null { $parameter = 'reference'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]); if (self::getParameter($parameter) === null) return null; self::requireMinLength($parameter, 0); self::requireMaxLength($parameter, 100); return (string)self::getParameter($parameter); } private function getTargetPo(bool $required = false): string|null { $parameter = 'po'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]); if (self::getParameter($parameter) === null) return null; self::requireMinLength($parameter, 0); self::requireMaxLength($parameter, 100); return (string)self::getParameter($parameter); } private function getTargetPickup(bool $required = false): bool|null { $parameter = 'pickup'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireType(self::getParameter($parameter), self::type_bool()); return (bool)self::getParameter($parameter); } /** * @throws Exception */ private function getTargetItems( bool $required = true, ?users_o $customer = null, ?int $department_id = null ): array|null { global $response; $parameter = 'items'; $error = 'Invalid items'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); self::requireType(self::getParameter($parameter), self::type_array()); $items = (array)self::getParameter($parameter); if (count($items) === 0) { $response->error($error, 400); } // Verify items foreach ($items as $key => $item) { self::requireType($item, self::type_array()); self::requireValidItem((array)$item, $key); $items[$key] = $this->normalizeBookingItem((array)$item, $customer, $department_id); } return $items; } /** * Enforce customer product rules again at the booking write boundary. * * The browser uses the same rules for guidance, but a stale or crafted * request must not be able to persist a restricted base product or add-on. * * @param list> $items */ private function requireBookingItemsAllowedForCustomer(users_o $customer, array $items): void { global $response; $customerNumber = (int)$customer->customer_number->value(); $restrictionService = new customer_rule_product_restriction_service(); foreach ($items as $item) { $violation = $restrictionService->violationForCustomerProduct( $customerNumber, (int)($item['id'] ?? 0) ); if ($violation === null) { continue; } $response->error([ 'code' => $violation['code'], 'message' => $violation['message'], 'product_id' => $violation['product_id'], 'rules' => $violation['rules'], 'collections' => $violation['collections'], ], 400); } } /** * @throws Exception */ private function normalizeBookingItem(array $item, ?users_o $customer, ?int $department_id): array { global $response; $product = (new products_o())->select((int)$item['id']); if (!$product->exists()) { $response->error('Invalid item id', 400); } $price = (int)$product->price->value(); if ($department_id !== null) { $priceResolution = $product->getDepartmentPriceResolution($department_id); $price = (int)$priceResolution['price']; if ( $customer !== null && $customer->exists() && !products_o::priceResolutionIsCustomMissing($priceResolution) ) { $price = $customer->applyProductCustomerPricing( (int)$product->id, $price, true, $department_id ); } } elseif ($customer !== null && $customer->exists()) { $price = $customer->applyProductCustomerPricing((int)$product->id, $price); } return [ ...$item, 'id' => (int)$product->id, 'name' => (string)$product->name->value(), 'quantity' => (int)$item['quantity'], 'price' => $price, ]; } private function cleanReg(string $reg): string { // Remove all non-alphanumeric characters return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $reg)); } private function requireValidItem(array $item, int $index): void { global $response; /** * { * "id": 22, * "name": "Double Duty - Kemi", * "description": " ", * "price": 239, * "subscription_allowed": 0, * "category": "4", * "piktogram": "", * "economic_product_id": "24", * "apply_category_discount": false, * "requires_note": true, * "is_wash": false, * "display_in_booking_form": true, * "order_priority": 1021, * "created_at": "2024-12-09 14:31:49", * "updated_at": "2025-10-28 13:55:58", * "quantity": 4 * } */ // Require id field if (!isset($item['id']) || !is_int($item['id']) || $item['id'] < 1) { $response->error('Invalid item id at index ' . $index, 400); } // Require quantity field if (!isset($item['quantity']) || !is_int($item['quantity']) || $item['quantity'] < 1) { $response->error('Invalid item quantity at index ' . $index, 400); } } private function buildBookingCreationFingerprint(array $data): string { $payload = [ 'customer_number' => (int)($data['customer_number'] ?? 0), 'department' => (int)($data['department'] ?? 0), 'reg_1' => (string)($data['reg_1'] ?? ''), 'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null), 'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null), 'datetime' => $this->normalizeDatetimeForFingerprint((string)($data['datetime'] ?? '')), 'note' => $this->normalizeNullableString($data['note'] ?? null), 'reference' => $this->normalizeNullableString($data['reference'] ?? null), 'po' => $this->normalizeNullableString($data['po'] ?? null), 'pickup' => (bool)($data['pickup'] ?? false), 'items' => $this->normalizeItemsForFingerprint((array)($data['items'] ?? [])), ]; $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($encoded === false) { return md5((string)microtime(true)); } return hash('sha256', $encoded); } private function normalizeNullableString(mixed $value): ?string { if ($value === null) { return null; } $normalized = trim((string)$value); if (strtolower($normalized) === 'null') { return null; } return $normalized; } private function normalizeItemsForFingerprint(array $items): array { $normalized = []; foreach ($items as $item) { if (!is_array($item)) { continue; } $id = (int)($item['id'] ?? 0); $quantity = (int)($item['quantity'] ?? 0); if ($id < 1 || $quantity < 1) { continue; } $normalized[] = [ 'id' => $id, 'quantity' => $quantity, ]; } usort($normalized, static function (array $a, array $b): int { if ($a['id'] === $b['id']) { return $a['quantity'] <=> $b['quantity']; } return $a['id'] <=> $b['id']; }); return $normalized; } private function normalizeDatetimeForFingerprint(string $datetime): string { $value = trim($datetime); if ($value === '') { return ''; } $formats = ['Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d']; foreach ($formats as $format) { $parsed = \DateTime::createFromFormat($format, $value); if ($parsed !== false) { // Use minute precision so rapid retries do not bypass idempotency because of second-level drift. return $parsed->format('Y-m-d H:i'); } } return $value; } private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o { try { $redis = $this->resolveRedisClient(); if ($redis === null) { return null; } $id = (int)($redis->get($this->bookingIdempotencyResultKey($fingerprint)) ?? 0); if ($id < 1) { return null; } $booking = (new order_bookings_o())->select($id); if (!$booking->exists()) { return null; } return $booking; } catch (\Throwable) { return null; } } private function reserveBookingCreationSlot(string $fingerprint): bool { try { $redis = $this->resolveRedisClient(); if ($redis === null) { return true; } return $redis->set_if_absent_with_expiration( $this->bookingIdempotencyLockKey($fingerprint), '1', $this->bookingIdempotencyLockTtlSeconds() ); } catch (\Throwable) { // Fail open when Redis is unavailable to avoid blocking all bookings. return true; } } private function storeBookingIdempotencyResult(string $fingerprint, int $bookingId): void { try { $redis = $this->resolveRedisClient(); if ($redis === null) { return; } $redis->setEx( $this->bookingIdempotencyResultKey($fingerprint), (string)$bookingId, $this->bookingIdempotencyResultTtlSeconds() ); $redis->delete($this->bookingIdempotencyLockKey($fingerprint)); } catch (\Throwable) { // Best effort only. } } private function clearBookingCreationSlot(string $fingerprint): void { try { $redis = $this->resolveRedisClient(); if ($redis === null) { return; } $redis->delete($this->bookingIdempotencyLockKey($fingerprint)); } catch (\Throwable) { // Best effort only. } } private function resolveRedisClient(): ?redis { try { if (defined('redis')) { $instance = constant('redis'); if ($instance instanceof redis) { return $instance; } } return (new redis())->connect(); } catch (\Throwable) { return null; } } private function bookingIdempotencyLockKey(string $fingerprint): string { return 'order_booking:idempotency:lock:' . $fingerprint; } private function bookingIdempotencyResultKey(string $fingerprint): string { return 'order_booking:idempotency:result:' . $fingerprint; } private function bookingIdempotencyLockTtlSeconds(): int { return 30; } private function bookingIdempotencyResultTtlSeconds(): int { return 300; } }