hasTransaction()) { return; } // Check if a wash certificate is attached to the order if (!self::getOrder()->hasWashCertificateAttached()) { return; } // Get the customer from the booking $customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_number->value()); if (!$customer->exists()) { return; } // Check if the customer wants email notifications if (!$customer->wantsEmailNotifications()) { return; } // Get the email $customer_email = !empty($customer->wash_certificate_email->value()) ? $customer->wash_certificate_email->value() : $customer->email->value(); if (empty($customer_email)) { return; } // Send wash certificate email $email = new email(); $email->sendWashCertificateEmailToCustomer($this); } public function structure(): void { $this->setTable('order_bookings'); } /** * Add an object * @param array $data The additional data of the object (e.g. ["key" => "value"]) * @return void * @throws Exception If the object was not created successfully */ public function add( array $data, ): void { global /** @var db $db */ $db; // Add the object to the database $new_id = self::add_object($data); self::select($new_id); $this->onAfterNewBooking(); } /** * @throws Exception */ public function onAfterNewBooking(): void { self::requireSelected(); $this->notifyNewBooking(); // Notify the department about the new booking } /** * Notify the department about a new booking * @throws Exception If the object is not selected * @throws ClientExceptionInterface If the request to the API fails */ public function notifyNewBooking(): void { self::requireSelected(); $department_notification = true; $customer_notification = true; /** * Get the department, branding and customer */ // Get the department from the booking $department = (new departments_o())->select((int)$this->department->value()); if (!$department->exists()) { throw new Exception('Department not found'); } // Get the branding from the department $branding = (new branding_o())->select((int)$department->branding->value()); if (!$branding->exists()) { throw new Exception('Branding not found'); } // Get the customer from the booking $customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_number->value()); $department_array = $department->asArray(); $customer_array = $customer->asArray(); $message = ""; $message .= "Ny booking fra {$customer->getCustomerName($customer_array['customer_number'])} (Kundenr: {$customer_array['customer_number']}, Bookingnr: {$this->id})\n"; $message .= "Dato: " . date('d-m-Y H:i', strtotime($this->datetime->value())) . "\n"; $message .= "Køretøj: " . $this->reg_1->value() . (!empty($this->reg_2->value()) ? ", " . $this->reg_2->value() : "") . ( !empty($this->reg_3->value()) ? ", " . $this->reg_3->value() : "" ) ."\n"; // If the booking has a note, add it to the message if (!empty($this->note->value())) $message .= "Note: " . $this->note->value() . "\n"; // If the booking has items, add them to the message if (!empty($this->items->value())) { $items = array_map(function ($item) { // Get the product name $product = (new products_o())->select((int)$item['id']); if (!$product->exists()) { return null; } $item['name'] = $product->name->value(); if (!isset($item['quantity'])) { $item['quantity'] = 1; } return "- " . $item['quantity'] . " x " . $item['name']; }, $this->items->value()); $message .= "Ydelser:\n" . implode("\n", array_filter($items)) . "\n"; } // If the booking has a reference, add it to the message if (!empty($this->reference->value())) $message .= "Reference: " . $this->reference->value() . "\n"; // If the booking has a PO, add it to the message if (!empty($this->po->value())) $message .= "PO: " . $this->po->value() . "\n"; if ($this->pickup->value()) $message .= "Afhentning: Ja\n"; else $message .= "Afhentning: Nej\n"; // If the booking has a note, add it to the message if (!empty($this->note->value())) $message .= "Note: " . $this->note->value() . "\n"; // Add the branding name $message = "*" . $branding->name->value() . "*\n" . $message; /** * Department booking notification */ if ($department_notification) { // Define delivery methods $deliverSlack = (boolean)!empty($department->slack_webhook->value()); $deliverSMS = true; // Check if the department has a slack webhook if ($deliverSlack) { // Construct email $message = "*" . $branding->name->value() . "*\n"; $message .= "Ny booking fra *" . $customer->getCustomerName($customer_array['customer_number']) . "* (Kundenr: " . $customer_array['customer_number'] . ", Bookingnr: " . $this->id . ")\n"; $message .= "Dato: " . date('d-m-Y H:i', strtotime($this->datetime->value())) . "\n"; $message .= "Køretøj: " . $this->reg_1->value() . (!empty($this->reg_2->value()) ? ", " . $this->reg_2->value() : "") . ( !empty($this->reg_3->value()) ? ", " . $this->reg_3->value() : "" ) ."\n"; // If the booking has a note, add it to the message if (!empty($this->note->value())) { $message .= "Note: " . $this->note->value() . "\n"; } // If the booking has items, add them to the message $items = array_map(function ($item) { // Get the product name $product = (new products_o())->select((int)$item['id']); if (!$product->exists()) { return null; } $item['name'] = $product->name->value(); if (!isset($item['quantity'])) { $item['quantity'] = 1; } return "- " . $item['quantity'] . " x " . $item['name']; }, $this->items->value()); if (!empty($items)) { $message .= "Ydelser:\n" . implode("\n", array_filter($items)) . "\n"; } // Send a notification to the department $slack = new slack(); $slack->send_department_booking_notification($department->id, $message); } if ($deliverSMS) { // Send an SMS notification to the department $gatewayapi = new gatewayapi(); try { $gatewayapi->send( [ // Add all the phone numbers from the department ...$department->notificationSmsPhoneNumbers() ], $message, true ); } catch (Exception $e) { // Log the error $logs = new logs_o(); $logs->add('gatewayapi', 0, 3, 0, 'SEND_DEPARTMENT_BOOKING_NOTIFICATION', $e->getMessage()); } } } /** * Customer booking confirmation */ if ($customer_notification) { // Check if the customer has a phone number $country_code = $customer->phone_country_code->value(); $customer_phone = $customer->phone->value(); $customer_email = $customer->getWashCertificateEmail(); if (!$customer_email) { $customer_email = $customer->email->value(); } if (empty($customer_phone) && empty($customer_email)) { return; } if (!empty($customer_phone) && $customer->wantsSmsNotifications()) { // Send an SMS confirmation to the customer $gatewayapi = new gatewayapi(); $message = "Tak for din booking hos " . $branding->name->value() . ". Dit bookingnummer er " . $this->id . ". " . "\nDato: " . date('d-m-Y', strtotime($this->datetime->value())) . "." . "\nKøretøj: " . $this->reg_1->value() . (!empty($this->reg_2->value()) ? ", " . $this->reg_2->value() : "") . ( !empty($this->reg_3->value()) ? ", " . $this->reg_3->value() : "" ) .". " . "\nAdresse: " . $branding->address->value() . ". " . "\nVi glæder os til at se dig!"; try { $gatewayapi->send( [ (string)$country_code . (string)$customer_phone, ], $message, true ); } catch (Exception $e) { // Log the error $logs = new logs_o(); $logs->add('gatewayapi', 0, 3, 0, 'SEND_CUSTOMER_BOOKING_CONFIRMATION', $e->getMessage()); } } // Check if the customer has an email if (!empty($customer_email) && $customer->wantsEmailNotifications()) { // Send an email confirmation to the customer $email = new email(); $email->sendOrderBookingConfirmationEmail($this); } // TODO: Send booking confirmation to customer } } public function getObjectProperties(): void { $this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false); $this->department = new object_property($this->table, $this->id, 'department', 'int', false); $this->reg_1 = new object_property($this->table, $this->id, 'reg_1', 'string', false); $this->reg_2 = new object_property($this->table, $this->id, 'reg_2', 'string', false); $this->reg_3 = new object_property($this->table, $this->id, 'reg_3', 'string', false); $this->datetime = new object_property($this->table, $this->id, 'datetime', 'timestamp', false); $this->note = new object_property($this->table, $this->id, 'note', 'string', false); $this->reference = new object_property($this->table, $this->id, 'reference', 'string', false); $this->po = new object_property($this->table, $this->id, 'po', 'string', false); $this->pickup = new object_property($this->table, $this->id, 'pickup', 'bool', false); $this->items = new object_property($this->table, $this->id, 'items', 'json', false); $this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); } public function objectChanged(): void { try { order_bookings_list_cache::clearAll(); order_bookings_counts_cache::clearAll(); } catch (\Throwable) { // Cache invalidation must never break order-booking writes. } } /** * @throws Exception */ public function asArray(): array { $order_id = $this->order_id->value(); return [ 'id' => (int)$this->id, 'customer_number' => (int)$this->customer_number->value(), 'department' => (int)$this->department->value(), 'reg_1' => $this->reg_1->value(), 'reg_2' => $this->reg_2->value(), 'reg_3' => $this->reg_3->value(), 'datetime' => $this->datetime->value(), 'note' => $this->note->value(), 'reference' => $this->reference->value(), 'po' => $this->po->value(), 'pickup' => (bool)$this->pickup->value(), 'items' => $this->parseProductNames($this->items->value()), 'order_id' => $order_id === null ? null : (int)$order_id, 'customer_name' => (new users_o())->getUserByCustomerNumber((int)$this->customer_number->value())->getCustomerName((int)$this->customer_number->value()), 'created_at' => $this->created_at->value(), 'updated_at' => $this->updated_at->value(), ]; } /** * @throws Exception */ private function parseProductNames(array $items): array { $parsedItems = []; foreach ($items as $item) { if (!isset($item['id']) || !isset($item['quantity'])) { continue; } $product = (new products_o())->select((int)$item['id']); if (!$product->exists()) { continue; } $parsedItems[] = [ ...$item, 'id' => (int)$product->id, 'name' => (string)$product->name->value(), 'quantity' => (int)$item['quantity'], ]; } return $parsedItems; } /** * @throws Exception */ public function completeBooking(int $user_id, ?string $safety_seal = null): void { self::requireSelected(); if (!$this->order_id->value()) { // Create order, if not already created $this->createOrderBy($user_id); // Add order items, re-calculate the prices to be customer-specific $this->createOrderItemsBy($user_id); } $order = $this->getOrder(); if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) { return; } $this->requireLinkedOrderMatchesBooking($order); $normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal); if ($normalizedSafetySeal !== null) { $order->setSafetySealValue($normalizedSafetySeal); $order->objectChanged(); } if ($order->hasWashCertificateAttached()) { return; } $this->attachWashCertificate($user_id, $order->getSafetySealValue()); if ($order->hasWashCertificateAttached()) { $this->sendWashCertificateToCustomer(); } } /** * @throws Exception */ public function createOrderBy(int $user_id): void { self::requireSelected(); // Create order $orders = new orders_o(); $orders->createOrderFromBooking($this->id, $user_id); // Set the order id in the booking $this->order_id->set((int)$orders->id); self::objectChanged(); } /** * @throws Exception */ public function createOrderItemsBy(int $user_id): void { self::requireSelected(); // Get the order $order = self::getOrder(); $order->requireSelected(); // Add order items foreach ($this->items->value() as $item) { if (!isset($item['id']) || !isset($item['quantity'])) { continue; } $orderItems = new order_items_o(); $itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== '' ? (string)$item['notes'] : ((string)($this->note->value() ?? '') ?: null); $orderItems->addItemToOrder( (int)$order->id, (int)$item['id'], (int)$user_id, (int)$item['quantity'], null, $itemNotes, ); } } /** * @throws Exception */ public function containsWashCertificateItem(): bool { self::requireSelected(); foreach ($this->items->value() as $item) { $product_id = (int)($item['id'] ?? 0); if ($product_id <= 0) { continue; } $product = (new products_o())->select($product_id); if ($product->exists() && $product->isWashCertificate()) { return true; } } return false; } /** * @throws Exception */ public function getOrder(): orders_o { self::requireSelected(); if (!$this->order_id->value()) { throw new \Exception('Order not found for this booking'); } $order = new orders_o(); $order->select((int)$this->order_id->value()); if (!$order->exists()) { throw new \Exception('Order not found for this booking'); } return $order; } /** * @throws Exception */ private function requireLinkedOrderMatchesBooking(orders_o $order): void { self::requireSelected(); $bookingCustomerNumber = (int)$this->customer_number->value(); $bookingDepartmentId = (int)$this->department->value(); $orderCustomerId = (int)$order->customer_id->value(); $orderDepartmentId = (int)$order->department_id->value(); if ($orderCustomerId !== $bookingCustomerNumber || $orderDepartmentId !== $bookingDepartmentId) { throw new Exception('Linked order does not match booking customer or department'); } } /** * @throws Exception */ protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void { self::requireSelected(); $order = $this->getOrder(); $this->requireLinkedOrderMatchesBooking($order); // Check if the order already has a wash certificate attached if ($order->hasWashCertificateAttached()) { return; } // Get the operator name $operator = (new users_o())->select($user_id); if (!$operator->exists()) { throw new Exception('Operator not found'); } // Generate wash certificate $this->generateWashCertificate($safety_seal, $operator->display_name->value()); } /** * Generate a wash certificate * @param int|null $safety_seal The safety seal number * @param string|null $operator The operator name * @throws Exception If the object is not selected * @throws Exception If the booking already has a wash certificate */ public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null): void { self::requireSelected(); // Generate the wash certificate // Get the department from the booking $department = (new departments_o())->select((int)$this->department->value()); if (!$department->exists()) { throw new Exception('Department not found'); } // Get the branding from the department $branding = (new branding_o())->select((int)$department->branding->value()); if (!$branding->exists()) { throw new Exception('Branding not found'); } // Get the customer from the booking $customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_number->value()); $department_array = $department->asArray(); $customer_array = $customer->asArray(); $booking_array = self::asArray(); $department_array['branding'] = $branding->asArray(); //print_r($department_array); //print_r($customer_array); //print_r($booking_array); $pdf_generator = new pdf_generator(); $pdf_generator->add_html($pdf_generator->templates->getTemplate('wash_certificate') ->setCompany([ 'name' => 'Truck Wash', 'address' => 'Letland Allé 2', 'zip' => 2630, 'city' => 'Taastrup', 'phone_prefix' => 45, 'phone' => 43717886, 'email' => 'cph@truckwash.dk', 'website' => 'www.truckwash.dk', 'images' => [ 'logo' => '/truckwash-banner-png.png', 'banner' => '/truckwash-banner-png.png', 'signature' => '/truckwash-underskrift.png', ], ]) ->addData([ 'booking_number' => $this->id, 'seal_number' => orders_o::normalizeSafetySealValue($safety_seal), 'reg_1' => $booking_array['reg_1'], 'reg_2' => $booking_array['reg_2'], 'date' => date('d-m-Y'), 'time' => date('H:i'), 'carried_out_by' => ($operator ?? null), 'department_id' => $department->id, 'department_name' => $department_array['branding']['name'] ?: $department_array['name'], 'department_address' => $department_array['branding']['address'] ?: $department_array['description'], 'customer_name' => $customer->getCustomerName($customer_array['customer_number']), 'customer_address' => $customer->getCustomerEcocomicData($customer_array['customer_number'])->economic_customer->address ?: '-', 'wash_type' => 'BOOK_WASH' ]) ->getHtml() ); $pdf_path = $pdf_generator->generate_pdf(); // Set the PDF in the order $this->getOrder()->addAttachment(new attachment_content((object)['document' => $pdf_path, 'other' => 'wash_certificate'])); } /** * @throws Exception */ public function hasTransaction(): bool { self::requireSelected(); return !empty($this->order_id->value()); } public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, string $date = null): int { global /** @var db $db */ $db; if ($date === null) { $date = date('Y-m-d'); } // Sanitize date $date = date('Y-m-d', strtotime($date)); $order_ids = self::getFieldsWhere([ 'department' => (int)$department_id, 'DATE(datetime)' => $date, 'order_id' => null, ], ['id']); return count($order_ids); } /** * @param array|null $departmentIds * @return array{past:int,current:int,future:int} */ public function getPendingBookingCounts(?array $departmentIds = null, ?int $customerNumber = null, ?\DateTimeImmutable $reference = null): array { global /** @var db $db */ $db; $normalizedDepartmentIds = []; if (is_array($departmentIds)) { foreach ($departmentIds as $departmentId) { $normalizedDepartmentId = (int)$departmentId; if ($normalizedDepartmentId > 0) { $normalizedDepartmentIds[] = $normalizedDepartmentId; } } $normalizedDepartmentIds = array_values(array_unique($normalizedDepartmentIds)); if ($normalizedDepartmentIds === []) { return [ 'past' => 0, 'current' => 0, 'future' => 0, ]; } } $now = $reference ?? new \DateTimeImmutable('now'); $todayStart = $now->setTime(0, 0, 0); $todayEnd = $now->setTime(23, 59, 59); $todayStartSql = $db->escape_string($todayStart->format('Y-m-d H:i:s')); $todayEndSql = $db->escape_string($todayEnd->format('Y-m-d H:i:s')); $whereClauses = [ '`deleted_at` IS NULL', "(`order_id` IS NULL OR `order_id` = 0 OR TRIM(CAST(`order_id` AS CHAR)) = '')", ]; if ($normalizedDepartmentIds !== []) { $whereClauses[] = '`department` IN (' . implode(', ', array_map('intval', $normalizedDepartmentIds)) . ')'; } if ($customerNumber !== null && $customerNumber > 0) { $whereClauses[] = '`customer_number` = ' . (int)$customerNumber; } $sql = "SELECT COALESCE(SUM(CASE WHEN `datetime` < '{$todayStartSql}' THEN 1 ELSE 0 END), 0) AS `past`, COALESCE(SUM(CASE WHEN `datetime` >= '{$todayStartSql}' AND `datetime` <= '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `current`, COALESCE(SUM(CASE WHEN `datetime` > '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `future` FROM `order_bookings` WHERE " . implode(' AND ', $whereClauses); $result = $db->query($sql); $row = $db->fetch_assoc($result); return [ 'past' => max(0, (int)($row['past'] ?? 0)), 'current' => max(0, (int)($row['current'] ?? 0)), 'future' => max(0, (int)($row['future'] ?? 0)), ]; } }