## Why Customer `k.sand@ksand.dk` reported never receiving wash certificates for completed bookings. Two methods contained silent early-return guards so the actual reason was unobservable from container logs: - `order_bookings_o::sendWashCertificateToCustomer()` — 5 silent returns - `email::sendWashCertificateEmailToCustomer()` — 1 silent return The most likely root cause: `email_notifications_enabled` defaults to `0` in the schema and `users.add()` does not set it on insert, so newly imported customers have notifications off until toggled. `wantsEmailNotifications()` then returns false and the email silently skips. ## What changed ### Original commit (`0ead5de5`) - `objects/order_bookings_o.php` — all 5 silent early-returns now log via new `logWashCertificateSkip()` helper (Redis stream `module=email / action=WASH_CERT_SKIP` + `error_log('[wash-cert-skip] …')`). - `classes/email.php` — silent `hasTransaction()` return in `sendWashCertificateEmailToCustomer()` now logs too. - `objects/bookings_o.php` — emits `WASH_CERT_SKIP` (legacy_no_wash_certificate_email) when `washCertificateEmail` is empty; no behavioural change. - **New** `routes/washCertificateDebugRoute.php` — `GET /debug/wash-certificates/diagnose?customer_number=&from=&to=` (404 in prod via $DEBUG; superuser-auth otherwise) replays the decision tree and reports `blocking_reason` per booking. ### Follow-up commit (`46a59e4e`) — silent-failure sweep **PART A — silent returns / silent errors (10 fixes):** - `email::sendEmailMailerSend()` — blacklisted-recipient skip now logs with context. - `email::sendNewCustomerRegistrationNotifications()` — empty-email skip + per-recipient try/catch with error_log (was unprotected; a single MailerSend error broke the loop). - `bookings_new_o::generateWashCertificate()` — wrapped `sendWashCertificateEmail()` in try/catch with error_log and re-throw (same pattern as the k.sand fix). - `users_o::getCustomerName()` — replaced catch-and-swallow with structured error_log. - `users_o::getCustomerEcocomicData()` — same. - `bookingsRoute.php` — added booking-id context to 4 × `$response->error('Booking not found', 404)` calls. **PART B — cron paths (10 files):** Added error_log breadcrumb + try/catch to `CheckUnfulfilledBookings`, `ClearAllUsersEconomicCustomerDetails`, `ClearAllUsersEconomicCustomerDiscounts`, `RunXLVaskModuleCron`, `SyncBookings`, `SyncEconomicInvoiceStatus`, `SyncLogs`, `BackfillEconomicV2History`, `EnsureXLVaskAutomationSchema`, and 3 functions in `Cron.php`. Each uses a distinct `[cron-…]` prefix for grep-ability. **PART C — real bugs (2 fixed):** 1. `email::sendEmailMailerSend()` attachment `array_map` — the previous exception message emitted a binary blob because `$attachment[0]` was already overwritten by `file_get_contents()`. Now captures $path first. 2. `bookings_new_o::generateWashCertificate()` — booking persisted as `completed` before email was sent, with no try/catch. Fixed (see PART A). ## How to verify 1. Deploy to staging. 2. Hit `/debug/wash-certificates/diagnose?customer_number=<k.sand's customer_number>` as a superuser — the response lists every booking's `blocking_reason`. 3. Tail container logs for `[wash-cert-skip]`, `[email-skip]`, `[cron-…]`, and Redis stream `module=email` action `WASH_CERT_SKIP` to see real-world skips going forward. ## Follow-ups (out of scope) - Schema migration to default `email_notifications_enabled` to `1` and backfill non-empty-email customers. - Move `error_log` to a proper PSR-3 logger. ## Risk - Logging only + new debug endpoint (404-gated in prod). No behavioural change for any path that previously sent mail successfully. `php -l` could not be run in the original sandbox; please verify on your CI box before deploying. 🤖 Generated with [OpenClaw](https://openclaw.ai)
799 lines
32 KiB
PHP
799 lines
32 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()) {
|
|
self::logWashCertificateSkip('no_transaction', $this->id, (int)$this->customer_number->value());
|
|
return;
|
|
}
|
|
// Check if a wash certificate is attached to the order
|
|
if (!self::getOrder()->hasWashCertificateAttached()) {
|
|
self::logWashCertificateSkip('no_wash_certificate_attached', $this->id, (int)$this->customer_number->value());
|
|
return;
|
|
}
|
|
// Get the customer from the booking
|
|
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_number->value());
|
|
if (!$customer->exists()) {
|
|
self::logWashCertificateSkip('customer_not_found', $this->id, (int)$this->customer_number->value());
|
|
return;
|
|
}
|
|
// Check if the customer wants email notifications
|
|
if (!$customer->wantsEmailNotifications()) {
|
|
self::logWashCertificateSkip(
|
|
'email_notifications_disabled',
|
|
$this->id,
|
|
(int)$this->customer_number->value(),
|
|
[
|
|
'email_notifications_enabled' => (bool)$customer->email_notifications_enabled->value(),
|
|
'wash_certificate_email' => $customer->wash_certificate_email->value(),
|
|
'email' => $customer->email->value(),
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
// Get the email
|
|
$customer_email = !empty($customer->wash_certificate_email->value())
|
|
? $customer->wash_certificate_email->value()
|
|
: $customer->email->value();
|
|
if (empty($customer_email)) {
|
|
self::logWashCertificateSkip(
|
|
'no_recipient_email',
|
|
$this->id,
|
|
(int)$this->customer_number->value(),
|
|
[
|
|
'wash_certificate_email' => $customer->wash_certificate_email->value(),
|
|
'email' => $customer->email->value(),
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
// Send wash certificate email
|
|
$email = new email();
|
|
$email->sendWashCertificateEmailToCustomer($this);
|
|
}
|
|
|
|
/**
|
|
* Record a structured "wash certificate was skipped" event.
|
|
*
|
|
* Historically each silent early-return path in sendWashCertificateToCustomer()
|
|
* dropped the email without any breadcrumb, making delivery failures (such as
|
|
* customers like k.sand@ksand.dk never receiving certificates) very hard to
|
|
* diagnose without DB access. We now emit both a Redis-backed application
|
|
* log entry (visible via the existing logs_o pipeline) and a stderr-friendly
|
|
* error_log line so the same skip is grep-able in container logs.
|
|
*
|
|
* TODO: migrate to the project logger when one is available globally.
|
|
*
|
|
* @param array<string, mixed> $extra
|
|
*/
|
|
private static function logWashCertificateSkip(string $reason, int $booking_id, int $customer_number, array $extra = []): void
|
|
{
|
|
$context = array_merge([
|
|
'reason' => $reason,
|
|
'booking_id' => $booking_id,
|
|
'customer_number' => $customer_number,
|
|
], $extra);
|
|
try {
|
|
(new logs_o())->add(
|
|
'email',
|
|
'global',
|
|
3,
|
|
0,
|
|
'WASH_CERT_SKIP',
|
|
json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
} catch (\Throwable) {
|
|
// Logging must never block the booking flow.
|
|
}
|
|
// Also emit to PHP error stream so this is visible in container logs.
|
|
error_log('[wash-cert-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
/**
|
|
* Record a structured "customer was not notified about a new booking" event.
|
|
*
|
|
* Mirrors logWashCertificateSkip() above. notifyNewBooking() historically
|
|
* had several silent early-return paths (no contact info, no opt-in, no
|
|
* wash certificate item, etc.) so a booking could be persisted without
|
|
* any breadcrumb of why the customer never got a confirmation. We now
|
|
* emit a structured skip event for each guarded branch so the same
|
|
* incident is grep-able in container logs.
|
|
*
|
|
* TODO: migrate to the project logger when one is available globally.
|
|
*
|
|
* @param array<string, mixed> $extra
|
|
*/
|
|
private static function logBookingNotificationSkip(string $reason, int $booking_id, int $customer_number, array $extra = []): void
|
|
{
|
|
$context = array_merge([
|
|
'reason' => $reason,
|
|
'booking_id' => $booking_id,
|
|
'customer_number' => $customer_number,
|
|
], $extra);
|
|
try {
|
|
(new logs_o())->add(
|
|
'booking',
|
|
'global',
|
|
3,
|
|
0,
|
|
'BOOKING_NOTIFY_SKIP',
|
|
json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
} catch (\Throwable) {
|
|
// Logging must never block the booking flow.
|
|
}
|
|
// Also emit to PHP error stream so this is visible in container logs.
|
|
error_log('[booking-notify-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
|
|
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)) {
|
|
// Previously this was a silent return; the booking would be persisted
|
|
// but neither the customer nor any department channel would be told.
|
|
// Surface the skip so support / ops can detect customers with no
|
|
// usable contact info without having to grep for missing confirmations.
|
|
self::logBookingNotificationSkip('no_contact_info', $this->id, (int)$this->customer_number->value(), [
|
|
'department_id' => (int)$this->department->value(),
|
|
]);
|
|
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()) {
|
|
// Previously this was a silent return; a booking without a wash
|
|
// certificate item could be "completed" without producing any PDF
|
|
// and without any breadcrumb. Surface the skip so ops can detect
|
|
// department misconfigurations (e.g. a cashier selecting a
|
|
// non-wash-cert product) without having to read the booking log.
|
|
self::logBookingNotificationSkip('no_wash_certificate_item', $this->id, (int)$this->customer_number->value(), [
|
|
'department_id' => (int)$this->department->value(),
|
|
'order_id' => (int)$order->id,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$this->requireLinkedOrderMatchesBooking($order);
|
|
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
|
if ($normalizedSafetySeal !== null) {
|
|
$order->setSafetySealValue($normalizedSafetySeal);
|
|
$order->objectChanged();
|
|
}
|
|
|
|
if ($order->hasWashCertificateAttached()) {
|
|
// The certificate is already attached (e.g. a duplicate complete
|
|
// request from the POS). Previously this just returned without
|
|
// any logging, so re-completion attempts were invisible. Emit a
|
|
// skip breadcrumb so ops can correlate duplicate API calls.
|
|
self::logBookingNotificationSkip('wash_certificate_already_attached', $this->id, (int)$this->customer_number->value(), [
|
|
'department_id' => (int)$this->department->value(),
|
|
'order_id' => (int)$order->id,
|
|
]);
|
|
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()) {
|
|
// Defensive guard: completeBooking already short-circuits this
|
|
// case, but if attachWashCertificate is called from a custom flow
|
|
// we still want to know why the duplicate attach was skipped.
|
|
self::logBookingNotificationSkip('attach_skipped_already_attached', $this->id, (int)$this->customer_number->value(), [
|
|
'department_id' => (int)$this->department->value(),
|
|
'order_id' => (int)$order->id,
|
|
]);
|
|
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)),
|
|
];
|
|
}
|
|
|
|
}
|