Merge origin/master and resolve orders_o conflict
This commit is contained in:
@@ -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.
|
||||
@@ -262,10 +343,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 +352,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 +393,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 +460,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 +477,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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -568,6 +674,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(),
|
||||
@@ -575,6 +683,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(),
|
||||
];
|
||||
@@ -620,6 +729,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(
|
||||
[
|
||||
@@ -1054,6 +1167,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');
|
||||
@@ -1079,6 +1195,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
|
||||
@@ -1127,36 +1378,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
|
||||
@@ -1333,31 +1556,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1375,24 +1609,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
|
||||
@@ -1404,17 +1826,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'));
|
||||
@@ -1441,17 +1873,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()
|
||||
@@ -1501,6 +1933,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();
|
||||
}
|
||||
|
||||
@@ -1551,6 +2021,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[]
|
||||
@@ -1625,4 +2156,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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user