Add safety seal support to orders and related logic for wash certificates

- Introduced `safety_seal` column in the `orders` table.
- Updated order creation and completion logic to handle safety seal values.
- Enhanced order and booking classes to manage safety seal attachment and retrieval.
- Added tests to validate safety seal functionality in order processing.
This commit is contained in:
Jeppe Bundgaard
2026-04-14 10:51:25 +02:00
parent 98f3188a98
commit ebf7e820d5
18 changed files with 1161 additions and 151 deletions
+36 -23
View File
@@ -350,16 +350,32 @@ class order_bookings_o extends db
public function completeBooking(int $user_id, string $safety_seal = null): void
{
self::requireSelected();
$orderWasCreatedDuringCompletion = false;
if (!$this->order_id->value()) {
// Create order, if not already created
self::createOrderBy($user_id);
$this->createOrderBy($user_id);
// Add order items, re-calculate the prices to be customer-specific
self::createOrderItemsBy($user_id);
$this->createOrderItemsBy($user_id);
$orderWasCreatedDuringCompletion = true;
}
// Create a wash certificate (If applicable)
if (self::containsWashCertificateItem()) self::attachWashCertificate($user_id, $safety_seal);
// Send wash certificate
self::sendWashCertificateToCustomer();
if (!$this->containsWashCertificateItem()) {
return;
}
$order = $this->getOrder();
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
if ($normalizedSafetySeal !== null) {
$order->setSafetySealValue($normalizedSafetySeal);
$order->objectChanged();
}
if (!$orderWasCreatedDuringCompletion) {
return;
}
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
$this->sendWashCertificateToCustomer();
}
/**
@@ -407,21 +423,18 @@ class order_bookings_o extends db
public function containsWashCertificateItem(): bool
{
self::requireSelected();
return self::containsProductId(41);
}
foreach ($this->items->value() as $item) {
$product_id = (int)($item['id'] ?? 0);
if ($product_id <= 0) {
continue;
}
/**
* @throws Exception
*/
private function containsProductId(int $productId): bool
{
self::requireSelected();
$items = $this->items->value();
foreach ($items as $item) {
if (isset($item['id']) && (int)$item['id'] == $productId) {
$product = (new products_o())->select($product_id);
if ($product->exists() && $product->isWashCertificate()) {
return true;
}
}
return false;
}
@@ -445,11 +458,11 @@ class order_bookings_o extends db
/**
* @throws Exception
*/
private function attachWashCertificate(int $user_id, string $safety_seal = null): void
protected function attachWashCertificate(int $user_id, string $safety_seal = null): void
{
self::requireSelected();
// Check if the order already has a wash certificate attached
if (self::getOrder()->hasWashCertificateAttached()) {
if ($this->getOrder()->hasWashCertificateAttached()) {
return;
}
// Get the operator name
@@ -458,7 +471,7 @@ class order_bookings_o extends db
throw new Exception('Operator not found');
}
// Generate wash certificate
self::generateWashCertificate($safety_seal, $operator->display_name->value());
$this->generateWashCertificate($safety_seal, $operator->display_name->value());
}
/**
@@ -468,7 +481,7 @@ class order_bookings_o extends db
* @throws Exception If the object is not selected
* @throws Exception If the booking already has a wash certificate
*/
public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null): void
public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null): void
{
self::requireSelected();
// Generate the wash certificate
@@ -510,7 +523,7 @@ class order_bookings_o extends db
])
->addData([
'booking_number' => $this->id,
'seal_number' => ($safety_seal ?? null),
'seal_number' => orders_o::normalizeSafetySealValue($safety_seal),
'reg_1' => $booking_array['reg_1'],
'reg_2' => $booking_array['reg_2'],
'date' => date('d-m-Y'),
@@ -556,4 +569,4 @@ class order_bookings_o extends db
return count($order_ids);
}
}
}
+90 -8
View File
@@ -42,6 +42,7 @@ class orders_o extends db
public object_property $wash_id; // The XL Vask Wash ID, if any
public object_property $lane; // The lane used for the order, if any
public object_property $po; // The (optional) PO number, filled by the customer.
public object_property $safety_seal; // The optional safety seal value for wash certificates.
public object_property $using_hand_held; // Whether the order is being processed using a handheld device
/**
@@ -99,6 +100,7 @@ class orders_o extends db
$this->wash_id = new object_property($this->table, $this->id, 'wash_id', 'string', false);
$this->lane = new object_property($this->table, $this->id, 'lane', 'string', false);
$this->po = new object_property($this->table, $this->id, 'po', 'string', false);
$this->safety_seal = new object_property($this->table, $this->id, 'safety_seal', 'string', false);
$this->using_hand_held = new object_property($this->table, $this->id, 'using_hand_held', 'bool', false);
}
@@ -267,10 +269,8 @@ class orders_o extends db
* @throws Exception If the order is not selected
* @throws Exception If the order is already completed
*/
public function markAsCompleted(): void
public function markAsCompleted(string|null $operator = null): void
{
global /** @var db $db */
$db;
self::requireSelected();
// Check if the order is already completed
if ($this->completed_at->value() !== null) {
@@ -278,9 +278,15 @@ class orders_o extends db
}
// Set the completed_at property to the current timestamp
$this->completed_at->set(date('Y-m-d H:i:s'));
$sql = "UPDATE $this->table SET completed_at = '" . $this->completed_at->value() . "' WHERE id = " . $this->id;
$db->query($sql);
$washCertificateCreated = $this->completeWashCertificateIfNeeded(
$operator,
(string)$this->completed_at->value()
);
$this->objectChanged();
if ($washCertificateCreated && (int)$this->booking_id->value() > 0) {
$this->getOrderBooking()?->sendWashCertificateToCustomer();
}
}
/**
@@ -581,6 +587,7 @@ class orders_o extends db
'wash_id' => $this->wash_id->value(),
'lane' => $this->lane->value(),
'po' => $this->po->value(),
'safety_seal' => $this->getSafetySealValue(),
'closed_at' => (int)$this->invoice_collection_id->value() ? (new collected_order_invoices_o())->select((int)$this->invoice_collection_id->value())->closed_at->value() : null,
'pending_handheld' => $this->isPendingHandheld(),
];
@@ -1381,14 +1388,89 @@ class orders_o extends db
return false; // No wash certificate product found in the order items
}
/**
* @throws Exception
*/
public function containsWashCertificateItem(): bool
{
self::requireSelected();
$products = new products_o();
foreach ($this->getOrderItems((int)$this->id) as $item) {
$product_id = (int)($item['product_id'] ?? 0);
if ($product_id <= 0) {
continue;
}
$product = $products->select($product_id);
if ($product->exists() && $product->isWashCertificate()) {
return true;
}
}
return false;
}
public static function normalizeSafetySealValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_string($value)) {
$normalized = trim($value);
return $normalized === '' ? null : $normalized;
}
if (is_scalar($value)) {
$normalized = trim((string)$value);
return $normalized === '' ? null : $normalized;
}
return null;
}
/**
* @throws Exception
*/
public function getSafetySealValue(): ?string
{
self::requireSelected();
return self::normalizeSafetySealValue($this->safety_seal->value());
}
/**
* @throws Exception
*/
public function setSafetySealValue(mixed $value): void
{
self::requireSelected();
$normalized = self::normalizeSafetySealValue($value);
$this->safety_seal->set($normalized);
}
/**
* @throws Exception
*/
public function completeWashCertificateIfNeeded(string|null $operator = null, $date = null): bool
{
self::requireSelected();
if (!$this->containsWashCertificateItem() || $this->hasWashCertificateAttached()) {
return false;
}
$this->generateWashCertificate($this->getSafetySealValue(), $operator, $date);
return $this->hasWashCertificateAttached();
}
/**
* Generate and attach a wash certificate directly on an order (without a booking)
* @param int|null $safety_seal Optional safety seal number
* @param string|null $safety_seal Optional safety seal number
* @param string|null $operator Optional operator/employee name who carried out the wash
* @param string|DateTime|null $date Optional date of the wash (defaults to current date)
* @throws Exception If the order is not selected or required related objects are missing
*/
public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null, $date = null): void
public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null, $date = null): void
{
self::requireSelected();
// Avoid generating duplicate certificates
@@ -1437,7 +1519,7 @@ class orders_o extends db
])
->addData([
'booking_number' => $this->id, // Used as document number on the template
'seal_number' => ($safety_seal ?? null),
'seal_number' => self::normalizeSafetySealValue($safety_seal),
'reg_1' => $order_array['reg_1'],
'reg_2' => $order_array['reg_2'],
'date' => $date_formatted,
+46 -20
View File
@@ -30,6 +30,26 @@ class subuser_grants_o extends db
subusers_permission_node_key::BOOKINGS_DELETE,
];
private static function normalizePermissionsValue(mixed $raw): array
{
if ($raw === null || $raw === '') {
return [];
}
if (is_array($raw)) {
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
}
}
return [];
}
public function structure(): void
{
@@ -62,23 +82,7 @@ class subuser_grants_o extends db
'subuser' => (int)$this->subuser->value(),
'enabled' => (bool)$this->enabled->value(),
'note' => $this->note->value(),
'permissions' => (function ($raw) {
// Handle different representations from object_property:
// - When type is 'json', object_property::value() may already return an array
// - In older behavior, it could return a JSON string
// Normalize to an array for API output
if ($raw === null || $raw === '') {
return [];
}
if (is_array($raw)) {
return $raw;
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
return [];
})($this->permissions->value()),
'permissions' => self::normalizePermissionsValue($this->permissions->value()),
'created_at' => $this->created_at->value(),
'updated_at' => $this->updated_at->value(),
'deleted_at' => $this->deleted_at->value(),
@@ -122,11 +126,33 @@ class subuser_grants_o extends db
// Extract permissions from the grants
$permissions = [];
foreach ($grants as $grant) {
$grant_permissions = json_decode($grant['permissions'], true);
$grant_permissions = self::normalizePermissionsValue($grant['permissions'] ?? null);
if (is_array($grant_permissions)) {
$permissions = array_merge($permissions, $grant_permissions);
}
}
return $permissions;
return array_values(array_unique($permissions));
}
}
public function getGrantForSubuserAndCustomer(int $subuser_id, int $customer_number, bool $includeDisabled = true): ?subuser_grants_o
{
$grants = self::getFieldsWhere([
'billing_customer_number' => $customer_number,
'subuser' => $subuser_id,
'deleted_at' => null,
], ['id', 'enabled']);
if (!$includeDisabled) {
$grants = array_values(array_filter($grants, static fn (array $grant): bool => (int)($grant['enabled'] ?? 0) === 1));
}
if (count($grants) === 0) {
return null;
}
usort($grants, static fn (array $left, array $right): int => (int)$right['id'] <=> (int)$left['id']);
$grant = (new subuser_grants_o())->select((int)$grants[0]['id']);
$grant->getObjectProperties();
return $grant;
}
}
+34 -1
View File
@@ -261,6 +261,34 @@ class subusers_o extends db
return $subuser;
}
/**
* @throws Exception
*/
public function getSubuserByEmail(string $email): ?subusers_o
{
global $db;
$email = $db->escape_string($email);
$tmp = self::getFieldsWhere([
'email' => $email,
], ['id']);
if (count($tmp) === 0) {
return null;
}
$subuser = (new subusers_o())->select((int)$tmp[0]['id']);
$subuser->getObjectProperties();
return $subuser;
}
/**
* @throws Exception
*/
public function requiresSetup(): bool
{
self::requireSelected();
$password = $this->password->value();
return !is_string($password) || trim($password) === '';
}
/**
* @throws RandomException
* @throws Exception
@@ -277,6 +305,11 @@ class subusers_o extends db
return $session_token;
}
public function invalidateSessionToken(string $token): void
{
$this->deleteCached('session_token:' . $token, 'subuser_sessions');
}
/**
* @param string $token The session token
* @return subusers_o|null The subuser object or null if the token is invalid or expired
@@ -332,4 +365,4 @@ class subusers_o extends db
$grant = new subuser_user_grant((int)$this->id, (int)$customer_number);
return $grant->hasNode($permission_node_key);
}
}
}