Merge master into fix-sql-injection-in-vehicle-plate-lookup

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 21:07:49 +00:00
committed by GitHub
1996 changed files with 316091 additions and 7918 deletions
+652 -78
View File
@@ -5,6 +5,8 @@ namespace objects;
use attachments\helpers\attachment_content;
use classes\db;
use classes\email;
use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap;
use classes\pdf_generator;
use classes\motorapi;
use classes\object_property;
@@ -30,6 +32,7 @@ class orders_o extends db
public object_property $reg_3;
public economic_module_orders $economic_module_orders;
public object_property $created_at;
public object_property $include_in_invoice;
public object_property $deleted_at;
public object_property $completed_at;
@@ -39,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
/**
@@ -52,6 +56,7 @@ class orders_o extends db
public function structure(): void
{
orders_schema_bootstrap::ensureTables();
$this->setTable('orders');
}
@@ -85,6 +90,7 @@ class orders_o extends db
$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->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'timestamp', false);
$this->economic_module_orders = (new economic_module_orders())->getByOrderId($this->id);
$this->stripe_module_orders = (new stripe_module_orders_o())->select($this->id);
@@ -94,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);
}
@@ -147,6 +154,80 @@ class orders_o extends db
// Save the object
}
/**
* Return the data needed to decide whether deletion needs explicit confirmation.
*
* @return array{
* requires_confirmation: bool,
* protected_reasons: array<int, string>,
* order_item_count: int,
* attachment_count: int,
* completed_at: mixed
* }
* @throws Exception
*/
public function getDeleteProtectionSummary(): array
{
self::requireSelected();
$completedAt = $this->completed_at->value();
$orderItemCount = $this->countActiveOrderItems();
$attachmentCount = $this->countActiveOrderAttachments();
$protectedReasons = [];
if ($completedAt !== null) {
$protectedReasons[] = 'completed';
}
if ($orderItemCount > 0) {
$protectedReasons[] = 'order_items';
}
if ($attachmentCount > 0) {
$protectedReasons[] = 'attachments';
}
return [
'requires_confirmation' => count($protectedReasons) > 0,
'protected_reasons' => $protectedReasons,
'order_item_count' => $orderItemCount,
'attachment_count' => $attachmentCount,
'completed_at' => $completedAt,
];
}
/**
* @throws Exception
*/
private function countActiveOrderItems(): int
{
self::requireSelected();
global $db;
$orderId = (int)$this->id;
$result = $db->query("SELECT COUNT(*) AS total FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL");
if ($result && $row = $result->fetch_assoc()) {
return (int)($row['total'] ?? 0);
}
return 0;
}
/**
* @throws Exception
*/
private function countActiveOrderAttachments(): int
{
self::requireSelected();
global $db;
$orderId = (int)$this->id;
$result = $db->query("SELECT COUNT(*) AS total FROM object_attachments WHERE object_type = 'orders' AND object_id = {$orderId} AND deleted_at IS NULL");
if ($result && $row = $result->fetch_assoc()) {
return (int)($row['total'] ?? 0);
}
return 0;
}
/**
* @throws Exception If the order is not selected
* This function is called when the order object is changed.
@@ -189,6 +270,8 @@ class orders_o extends db
public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array
{
global $db;
$plate = $db->escape_string($plate);
$entries = max(1, $entries);
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries";
$result = $db->query($sql);
return $db->fetch_all($result);
@@ -262,10 +345,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) {
@@ -273,9 +354,16 @@ 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);
$this->setPendingHandheldIndicator(false);
$washCertificateCreated = $this->completeWashCertificateIfNeeded(
$operator,
(string)$this->completed_at->value()
);
$this->objectChanged();
if ($washCertificateCreated && (int)$this->booking_id->value() > 0) {
$this->getOrderBooking()?->sendWashCertificateToCustomer();
}
}
/**
@@ -307,6 +395,16 @@ class orders_o extends db
self::objectChanged();
}
/**
* @throws Exception
*/
public function clearStripeInvoicing(): void
{
self::requireSelected();
$this->stripe_module_orders->clear();
self::objectChanged();
}
public function addArray(array $order_array): orders_o
{
global $db, $response;
@@ -364,10 +462,11 @@ class orders_o extends db
* @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used.
* @throws Exception If the order is not selected
*/
public function assignToInvoiceCollection(int $invoiceCollectionId = null): void
public function assignToInvoiceCollection(int $invoiceCollectionId = null, bool $notifyChanges = true): void
{
// If the invoice collection id is not set, get the default invoice collection id
self::requireSelected();
$previousInvoiceCollectionId = (int)$this->invoice_collection_id->value();
// Get the customer
$customer = new users_o();
$customer_id = (int)$this->customer_id->value();
@@ -380,7 +479,16 @@ class orders_o extends db
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
// Assign the order to the invoice collection
$this->invoice_collection_id->set($invoiceCollectionId);
self::objectChanged();
if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) {
$previousCollection = new collected_order_invoices_o();
$previousCollection->select($previousInvoiceCollectionId);
if ($previousCollection->exists()) {
$previousCollection->objectChanged();
}
}
if ($notifyChanges) {
self::objectChanged();
}
}
/**
@@ -431,6 +539,7 @@ class orders_o extends db
{
global /** @var db $db */
$db;
$plate = $db->escape_string($plate);
$sql = "SELECT id FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5";
$result = $db->query($sql);
$orders = $db->fetch_all($result);
@@ -573,6 +682,8 @@ class orders_o extends db
'reg_3' => $this->reg_3->value(),
'completed_at' => $this->completed_at->value(),
'created_at' => $this->created_at->value(),
'include_in_invoice' => $this->getIncludeInInvoiceOverride(),
'include_in_invoice_effective' => $this->isIncludedInInvoicing(),
'deleted_at' => $this->deleted_at->value(),
'total_net_amount' => $this->temporary_net_amount ?: $this->getNetAmount(),
'invoice_collection_id' => (int)$this->invoice_collection_id->value(),
@@ -580,6 +691,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(),
];
@@ -625,6 +737,10 @@ class orders_o extends db
*/
public function getNetAmountForOrders(array $order_ids): array
{
if (empty($order_ids)) {
return [];
}
$order_items = new order_items_o();
$tmp = $order_items->getFieldsWhere(
[
@@ -1059,6 +1175,9 @@ class orders_o extends db
public function getTransactionsForCustomersInDateRange(array $customers, string $dateFrom, string $dateTo): array
{
global $db;
if (empty($customers)) {
return [];
}
// Validate the date range
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
throw new Exception('Invalid date range provided');
@@ -1084,6 +1203,141 @@ class orders_o extends db
return $transactions;
}
/**
* Get period transactions as plain rows grouped by customer number.
*
* This avoids hydrating one orders_o object per order for the invoicing period response.
*
* @param int[]|null $customers Null means all local customers with orders in the period.
* @return array<int, array<int, array<string,mixed>>>
* @throws Exception
*/
public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array
{
global $db;
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($dateFrom) > strtotime($dateTo)) {
throw new Exception('The start date cannot be after the end date');
}
$customerFilter = '';
if ($customers !== null) {
$customers = array_values(array_unique(array_filter(
array_map('intval', $customers),
static fn(int $customerNumber): bool => $customerNumber > 0
)));
if (empty($customers)) {
return [];
}
$customerFilter = ' AND o.customer_id IN (' . implode(',', $customers) . ')';
}
$dateFrom = $db->escape_string($dateFrom);
$dateTo = $db->escape_string($dateTo);
$sql = "
SELECT
o.id,
o.customer_id AS customer_number,
customer_user.user_id,
customer_user.customer_name,
o.created_at,
COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount,
CASE
WHEN COALESCE(o.invoice_collection_id, 0) > 0
THEN CASE WHEN COALESCE(coi.booked_invoice_id, 0) <> 0 THEN 1 ELSE 0 END
ELSE CASE WHEN COALESCE(emo.invoice_id, 0) <> 0 THEN 1 ELSE 0 END
END AS booked,
o.department_id,
o.reference,
o.po,
o.notes,
o.reg_1,
o.reg_2,
o.reg_3,
o.invoice_collection_id,
CASE
WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice
WHEN COALESCE(department_flags.exclude_from_invoicing, 0) = 1 THEN 0
ELSE 1
END AS include_in_invoice_effective
FROM {$this->table} o
INNER JOIN (
SELECT customer_number, MIN(id) AS user_id, MAX(display_name) AS customer_name
FROM users
WHERE customer_number IS NOT NULL AND customer_number <> 0
GROUP BY customer_number
) customer_user ON customer_user.customer_number = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id
LEFT JOIN economic_module_orders emo ON emo.id = o.id
LEFT JOIN (
SELECT department_id, MAX(value = 'true') AS exclude_from_invoicing
FROM department_variables
WHERE variable = 'exclude_from_invoicing'
GROUP BY department_id
) department_flags ON department_flags.department_id = o.department_id
WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}'
AND o.deleted_at IS NULL
{$customerFilter}
GROUP BY
o.id,
o.customer_id,
customer_user.user_id,
customer_user.customer_name,
o.created_at,
coi.booked_invoice_id,
emo.invoice_id,
o.department_id,
o.reference,
o.po,
o.notes,
o.reg_1,
o.reg_2,
o.reg_3,
o.invoice_collection_id,
o.include_in_invoice,
department_flags.exclude_from_invoicing
ORDER BY o.customer_id, o.created_at, o.id";
$result = $db->query($sql);
if (!$result || $result->num_rows === 0) {
return [];
}
$transactions = [];
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)$row['customer_number'];
if ($customerNumber < 1) {
continue;
}
$invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0);
$transactions[$customerNumber][] = [
'id' => (int)$row['id'],
'date' => (string)($row['created_at'] ?? ''),
'created_at' => (string)($row['created_at'] ?? ''),
'amount' => (float)($row['net_amount'] ?? 0),
'booked' => (int)($row['booked'] ?? 0) === 1,
'department_id' => (int)($row['department_id'] ?? 0),
'customer_number' => $customerNumber,
'reference' => (string)($row['reference'] ?? ''),
'po' => (string)($row['po'] ?? ''),
'notes' => (string)($row['notes'] ?? ''),
'reg_1' => (string)($row['reg_1'] ?? ''),
'reg_2' => (string)($row['reg_2'] ?? ''),
'reg_3' => (string)($row['reg_3'] ?? ''),
'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1,
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
'queue_status' => null,
'queue_job_id' => null,
'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null,
'customer_name' => (string)($row['customer_name'] ?? ''),
];
}
return $transactions;
}
/**
* Get orders with possible duplicates in a date range
* @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format
@@ -1132,36 +1386,8 @@ class orders_o extends db
'object' => (new orders_o())->select((int)$tmp['id'])
];
}
// Filter out orders with more than one entry for the same registration numbers (in a 24 hour period)
$possibleDuplicates = [];
// Loop through the registration numbers
foreach ( $orders as $reg_1 => $orderList ) {
// If there are more than one order for the same registration number, add it to the possible duplicates
if (count($orderList) > 1) {
// Loop through the orders and check if they are within 24 hours of each other
$filteredOrders = [];
foreach ( $orderList as $order ) {
// Check if the order is within 24 hours of the previous order (if any)
if (empty($filteredOrders)) {
$filteredOrders[] = $order; // Add the first order
} else {
// Check if the order is within 24 hours of the previous order
$firstOrderTime = strtotime($filteredOrders[0]['created_at']);
$currentOrderTime = strtotime($order['created_at']);
if ($currentOrderTime - $firstOrderTime <= 86400) { // 86400 seconds = 24 hours
$filteredOrders[] = $order; // Add the order to the filtered list
}
}
}
// If there are more than one order in the filtered list, add it to the possible duplicates
if (count($filteredOrders) > 1) {
$possibleDuplicates[$reg_1] = $filteredOrders;
}
}
}
// Return the possible duplicates
return $possibleDuplicates;
return invoicing_period_utils::filterPossibleDuplicates($orders, 86400);
}
public function setTemporaryNetAmount(float $amount): void
@@ -1338,31 +1564,42 @@ class orders_o extends db
{
// Get the original net amount for the order items, ignoring any temporary net amount set
self::requireSelected();
$order_items = $this->getOrderItems((int)$this->id);
//echo 'Calculating net amount for order ID ' . $this->id . ' with ' . count($order_items) . " items\n";
return array_sum(array_map(/**
* @throws Exception
*/ function ($item) {
if ( (int)$item['price'] > 0 && (int)$item['quantity'] > 0) {
return (int)$item['price'] * (int)$item['quantity'];
$order_items = (new order_items_o())->getFieldsWhere(
['order_id' => (int)$this->id],
['price', 'quantity', 'product_id']
);
if (empty($order_items)) {
return 0;
}
$total = 0;
$department_id = (int)$this->department_id->value();
$tmp_user = null;
$department_price_cache = [];
foreach ( $order_items as $item ) {
$price = (int)$item['price'];
$quantity = (int)$item['quantity'];
if ($price > 0 && $quantity > 0) {
$total += $price * $quantity;
continue;
}
// Otherwise we need to get the product price
$product = (new products_o())->select((int)$item['product_id']);
// Apply the customer discount if applicable
$department_price_original = (int)$product->getDepartmentPrice((int)$this->department_id->value());
// Get the customers user object
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
// Get the discount percentage for the customer
$discount = $tmp_user->getCustomPrice((int)$product->id, false);
// Calculate the final price after discount
$post_discount = (int)round($department_price_original * (1 - ($discount / 100))) * (int)$item['quantity'];
//echo 'Adding ' . $post_discount . ' for product ' . $product->name . ' (Original price: ' . $department_price_original . ', Discount: ' . $discount . '%, Quantity: ' . (int)$item['quantity'] . ")\n";
$actual_price = (int)$item['price'];
if ($actual_price !== $post_discount) {
//echo "Warning: The actual price ($actual_price) does not match the calculated price ($post_discount) for product " . $product->name . "\n";
$product_id = (int)$item['product_id'];
if (!isset($department_price_cache[$product_id])) {
$product = (new products_o())->select($product_id);
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
}
return $post_discount;
}, $order_items));
if ($tmp_user === null) {
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
}
$discount = $tmp_user->getCustomPrice($product_id, false);
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
$total += $post_discount;
}
return $total;
}
/**
@@ -1380,24 +1617,212 @@ class orders_o extends db
public function hasWashCertificateAttached(): bool
{
self::requireSelected();
// Check if the order has a wash certificate attached
$attachments = $this->listAttachments();
foreach ( $attachments as $attachment ) {
if ($attachment->isWashCertificate()) {
return true; // Wash certificate found
return $this->listWashCertificateAttachmentIds() !== [];
}
/**
* @return int[]
* @throws Exception
*/
protected function listWashCertificateAttachmentIds(): array
{
self::requireSelected();
global $db;
$rawObjectType = trim((string)$this->table, '`');
$objectTypes = array_values(array_unique([
$db->escape_string($rawObjectType),
$db->escape_string('`' . $rawObjectType . '`'),
]));
$quotedObjectTypes = "'" . implode("','", $objectTypes) . "'";
$objectId = (int)$this->id;
$sql = "SELECT id, content
FROM object_attachments
WHERE object_type IN ($quotedObjectTypes)
AND object_id = $objectId
AND deleted_at IS NULL";
$result = $db->query($sql);
if (!$result) {
return [];
}
$attachmentIds = [];
while ($row = $db->fetch_assoc($result)) {
$content = json_decode((string)($row['content'] ?? ''), true);
$other = is_array($content) ? ($content['other'] ?? null) : null;
if (is_string($other) && strtolower($other) === 'wash_certificate') {
$attachmentIds[] = (int)($row['id'] ?? 0);
}
}
return false; // No wash certificate product found in the order items
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
}
/**
* @throws Exception
*/
public function regenerateAttachedWashCertificate(): bool
{
self::requireSelected();
if (!$this->hasWashCertificateAttached()) {
return false;
}
$this->removeAttachedWashCertificates();
$this->generateWashCertificate(
$this->getSafetySealValue(),
$this->resolveWashCertificateOperator(),
$this->resolveWashCertificateDate()
);
return $this->hasWashCertificateAttached();
}
/**
* @throws Exception
*/
protected function removeAttachedWashCertificates(): int
{
self::requireSelected();
$attachmentIds = $this->listWashCertificateAttachmentIds();
if ($attachmentIds === []) {
return 0;
}
global $db;
$escapedIds = array_map(static fn(int $id): int => (int)$id, $attachmentIds);
$idList = implode(',', $escapedIds);
$sql = "UPDATE object_attachments
SET deleted_at = NOW()
WHERE id IN ($idList)
AND deleted_at IS NULL";
$db->query($sql);
return count($escapedIds);
}
/**
* @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 resolveWashCertificateOperator(): ?string
{
self::requireSelected();
$cashierId = (int)$this->cashier_id->value();
if ($cashierId <= 0) {
return null;
}
$cashier = (new users_o())->select($cashierId);
if (!$cashier->exists()) {
return null;
}
$displayName = trim((string)$cashier->display_name->value());
return $displayName === '' ? null : $displayName;
}
/**
* @throws Exception
*/
public function resolveWashCertificateDate(): string
{
self::requireSelected();
$candidate = $this->completed_at->value() ?: $this->created_at->value();
if (is_string($candidate) && trim($candidate) !== '') {
return $candidate;
}
return date('Y-m-d H:i:s');
}
/**
* @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
@@ -1409,17 +1834,27 @@ class orders_o extends db
if (!$department->exists()) {
throw new Exception('Department not found');
}
// Get branding for the department
$branding = (new branding_o())->select((int)$department->branding->value());
if (!$branding->exists()) {
throw new Exception('Branding not found');
// Branding is optional; fall back to the department values when it is not configured.
$branding = null;
$brandingId = (int)($department->branding->value() ?? 0);
if ($brandingId > 0) {
$selectedBranding = (new branding_o())->select($brandingId);
if ($selectedBranding->exists()) {
$branding = $selectedBranding;
}
}
// Get the customer for the order
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
$department_array = $department->asArray();
$department_array['branding'] = $branding->asArray();
$customer_array = $customer->asArray();
$order_array = $this->asArray();
$branding_array = $branding?->asArray() ?? [];
$customer_number = (int)$customer->customer_number->value();
$customer_name = trim((string)$customer->display_name->value());
if ($customer_name === '') {
$customer_name = (string)($customer_number > 0 ? $customer_number : $this->customer_id->value());
}
$customer_address = '-';
// Format the date as 17:35 02-12-2025
$date = ($date instanceof DateTime ? $date : ($date !== null ? new DateTime($date) : new DateTime()));
$date_formatted = ($date instanceof DateTime ? $date->format('d-m-Y') : date('d-m-Y'));
@@ -1446,17 +1881,17 @@ 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,
'time' => $time_formatted,
'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 ?: '-',
'department_name' => ($branding_array['name'] ?? null) ?: $department_array['name'],
'department_address' => ($branding_array['address'] ?? null) ?: $department_array['description'],
'customer_name' => $customer_name,
'customer_address' => $customer_address,
'wash_type' => 'ORDER_WASH'
])
->getHtml()
@@ -1506,6 +1941,44 @@ class orders_o extends db
public function isIncludedInInvoicing(): bool
{
self::requireSelected();
$override = $this->getIncludeInInvoiceOverride();
if ($override !== null) {
return $override;
}
return $this->resolveDepartmentIncludedInInvoicing();
}
public static function normalizeNullableBooleanValue(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value === 1 ? true : ($value === 0 ? false : null);
}
$normalized = strtolower(trim((string)$value));
return match ($normalized) {
'1', 'true' => true,
'0', 'false' => false,
default => null,
};
}
public function getIncludeInInvoiceOverride(): ?bool
{
self::requireSelected();
return self::normalizeNullableBooleanValue($this->include_in_invoice->value());
}
protected function resolveDepartmentIncludedInInvoicing(): bool
{
return !(new departments_o())->select((int)$this->department_id->value())->isExcludedFromInvoicing();
}
@@ -1556,6 +2029,67 @@ class orders_o extends db
return (int)$row['wash_count'];
}
/**
* @param array<int> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global /** @var db $db */
$db;
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
$normalized_department_ids = [];
foreach ($department_ids as $department_id) {
$normalized_id = (int)$department_id;
if ($normalized_id > 0) {
$normalized_department_ids[$normalized_id] = true;
}
}
if ($normalized_department_ids === []) {
return [];
}
$department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids)));
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT o.department_id,
DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(DISTINCT o.id) AS wash_count
FROM $this->table o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND p.is_wash = 1
GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
}
/**
* @throws Exception
* @retuns order_items_o[]
@@ -1630,4 +2164,44 @@ class orders_o extends db
}
return $orders;
}
/**
* @throws Exception
*/
public function getOrdersWithRegistrationNumberInDateRange(string $registration_number, string $from_date, string $to_date): array
{
global /** @var db $db */
$db;
// Validate the date range
$from_timestamp = strtotime($from_date);
$to_timestamp = strtotime($to_date);
if ($from_timestamp === false || $to_timestamp === false) {
throw new Exception('Invalid date range provided');
}
if ($from_timestamp > $to_timestamp) {
throw new Exception('The start date cannot be after the end date');
}
// Prepare the SQL query to find orders with the registration number in the date range
$reg = strtoupper(trim($registration_number));
if ($reg === '') {
return [];
}
$reg = $db->escape_string($reg);
$from_date = $db->escape_string(date('Y-m-d H:i:s', $from_timestamp));
$to_date = $db->escape_string(date('Y-m-d H:i:s', $to_timestamp));
$sql = "SELECT id FROM $this->table
WHERE (UPPER(TRIM(reg_1)) = '$reg' OR UPPER(TRIM(reg_2)) = '$reg' OR UPPER(TRIM(reg_3)) = '$reg')
AND created_at BETWEEN '$from_date' AND '$to_date'
AND deleted_at IS NULL";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return []; // No orders found with the registration number in the date range
}
$orders = [];
while ($row = $result->fetch_assoc()) {
$order = new orders_o();
$order->select((int)$row['id']);
$orders[] = $order;
}
return $orders;
}
}