961 lines
36 KiB
PHP
961 lines
36 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\order_bookings_counts_cache;
|
|
use classes\order_bookings_list_cache;
|
|
use classes\redis;
|
|
use Exception;
|
|
use modules\subusers\helpers\subusers_permission_node_key;
|
|
use objects\departments_o;
|
|
use objects\logs_o;
|
|
use objects\order_bookings_o;
|
|
use objects\order_items_o;
|
|
use objects\orders_o;
|
|
use objects\products_o;
|
|
use objects\users_o;
|
|
use traits\route_t;
|
|
|
|
class orderBookingRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
$this->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
|
|
$items = self::getTargetItems(); // Array of order_items_o objects
|
|
/**
|
|
* Permissions (clean helper)
|
|
*/
|
|
$permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
|
|
$permission_other = self::definePermission('add_bookings');
|
|
self::allowOwnOrDepartmentAccess(
|
|
$permission_own,
|
|
$permission_other,
|
|
(int)$customer_number->customer_number->value(),
|
|
(int)$department->id,
|
|
null,
|
|
'You do not have permission to create this order booking.'
|
|
);
|
|
/**
|
|
* 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);
|
|
} catch (\Throwable $e) {
|
|
$this->clearBookingCreationSlot($fingerprint);
|
|
throw $e;
|
|
}
|
|
$response->success($order_bookings_o->asArray());
|
|
},
|
|
[
|
|
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
|
|
'add_bookings' => 'Permission to create department order bookings.'
|
|
]
|
|
);
|
|
|
|
$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' => $user->getGroup()->getDepartments()
|
|
] : []),
|
|
...(!$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 = array_map('intval', $user->getGroup()->getDepartments());
|
|
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
|
|
$items = self::getTargetItems(false); // Array of order_items_o objects
|
|
$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.'
|
|
);
|
|
/**
|
|
* 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/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;
|
|
}
|
|
|
|
/**
|
|
* @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): 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]['name'] = (new products_o())->select((int)$item['id'])->name->value();
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}
|