Files
api/services/nginx/app/objects/order_bookings_o.php
T
Jeppe B 2a6a86c9c3 Resolve backend Qodana critical and high findings (#314)
Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
2026-07-17 05:44:16 +02:00

672 lines
26 KiB
PHP

<?php
namespace objects;
use Psr\Http\Client\ClientExceptionInterface;
use attachments\helpers\attachment_content;
use classes\db;
use classes\email;
use classes\gatewayapi;
use classes\object_property;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\pdf_generator;
use classes\slack;
use Exception;
use traits\db_object_t;
class order_bookings_o extends db
{
use db_object_t;
public object_property $customer_number;
public object_property $department;
public object_property $reg_1;
public object_property $reg_2;
public object_property $reg_3;
public object_property $datetime;
public object_property $note;
public object_property $reference;
public object_property $po;
public object_property $pickup;
public object_property $items;
public object_property $order_id;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
/**
* @throws Exception
*/
public function sendWashCertificateToCustomer(): void
{
self::requireSelected();
// Only send a wash certificate if the order has been created.
if (!$this->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 ($this->getOrder()->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<int, int|string>|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)),
];
}
}