setTable('orders'); } public function edit(int $id, int $customer_id, int $cashier_id, string $reference, string $notes, int $department_id): void { global $db, $response; $this->id = $id; try { // Avoid SQL injection $reference = $db->escape_string($reference); $notes = $db->escape_string($notes); // Update the record in the database $sql = "UPDATE $this->table SET customer_id = $customer_id, cashier_id = $cashier_id, reference = '$reference', notes = '$notes', department_id = $department_id WHERE id = $id"; $db->query($sql); // Set the values of the object properties $this->getObjectProperties(); } catch (Exception $e) { $response->error($e->getMessage()); } } public function getObjectProperties(): void { $this->customer_id = new object_property($this->table, $this->id, 'customer_id', 'int', true); $this->cashier_id = new object_property($this->table, $this->id, 'cashier_id', 'int', true); $this->reference = new object_property($this->table, $this->id, 'reference', 'string', true); $this->notes = new object_property($this->table, $this->id, 'notes', 'string', false); $this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', true); $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->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); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); $this->invoice_collection_id = new object_property($this->table, $this->id, 'invoice_collection_id', 'int', false); $this->booking_id = new object_property($this->table, $this->id, 'booking_id', 'int', false); $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); } public function getCustomerByOrderId(?string $order_id): users_o { global $db; $sql = "SELECT customer_id FROM orders WHERE id = $order_id"; $result = $db->query($sql); if ($result->num_rows > 0) { $row = $result->fetch_assoc(); return (new users_o())->getUserByCustomerNumber($row['customer_id']); } return new users_o(); } public function getCustomerOrdersPaginated(int $customer_number, int $page = 1, int $limit = 10, array $order = ['id' => 'DESC'], string $search = null, array $filters = null): array { global /** @var response $response */ $db, $response; // Add the customer number to the filters $filters['customer_id'] = $customer_number; // List the objects with pagination $array = $this->listObjectsWithPagination($page, $limit, $search, $filters, $order); // Get the total number of objects $total = $this->getTotalObjects($search, $filters); $response->paginate($page, $limit, $total); return $array; } /** * @throws Exception */ public function getDepartmentByOrderId($order_id): array { $tmp_order = new orders_o(); if ($order_id === null) { // Get the department id from the order self::requireSelected(); $order_id = $this->id; } return (new departments_o())->getDepartmentById((int)$tmp_order->select($order_id)->department_id->value(), true); } public function delete(): void { // Set the deleted_at property to the current timestamp $this->deleted_at->set(date('Y-m-d H:i:s')); $this->objectChanged(); // Save the object } /** * Return the data needed to decide whether deletion needs explicit confirmation. * * @return array{ * requires_confirmation: bool, * protected_reasons: array, * 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. */ public function objectChanged(): void { self::requireSelected(); // Reset the cached object self::deleteCached('asArray', $this->id); // Inform the order collection that the order has changed $this->getOrderCollection()->objectChanged(); } /** * Get the order collection for the order * @return collected_order_invoices_o The order collection * @throws Exception If the order is not selected */ public function getOrderCollection(): collected_order_invoices_o { self::requireSelected(); $order_collection = new collected_order_invoices_o(); $order_collection->select((int)$this->invoice_collection_id->value()); $order_collection->requireSelected(); return $order_collection; } public function restore(): void { // Set the deleted_at property to null $this->deleted_at->set(null); // Save the object } /** * @param string $plate The vehicle plate * @param int $entries The number of last entries to return (default 10) * @return array The orders for the vehicle plate */ 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); } public function unlinkOrdersFromInvoiceDraft(int $draftInvoiceNumber): void { $this->economic_module_orders->unlinkAllOrdersFromDraft($draftInvoiceNumber); } public function getOrderCustomer(int $orderId): users_o { $order = new orders_o(); $order->getOrderById($orderId); return (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value()); } public function getOrderById(int $id): orders_o { global $db; // Get the record from the database $sql = "SELECT * FROM $this->table WHERE id = $id"; $result = $db->query($sql); if ($result->num_rows > 0) { $this->id = $id; $this->getObjectProperties(); } return $this; } /** * This function is used when an order is updated * It takes the request data and updates the order accordingly. * @return void * @example {"id":410,"field":"reg_1","value":"REG12"} */ public function updateRequest(): void { global $response; // If the permission check is not skipped, require the user to be logged in $data = json_decode(file_get_contents('php://input'), true); $this->id = $data['id']; $this->getObjectProperties(); // Throw an error if the order does not exist if (!$this->exists()) { $response->error('Order not found, or already deleted', 400); } // Validate the field value if (!isset($data['field']) || !isset($data['value'])) { $response->error('Field and value are required', 400); } // Check if the value is null if ($data['value'] === 'null' || $data['value'] === 'NULL') { $this->{$data['field']}->nullify(); } $this->{$data['field']}->set($data['value']); } public function exists(): bool { // Check if the id is greater than 0, and that the deleted_at property is null if ($this->id > 0) { $this->getObjectProperties(); return $this->deleted_at->value() === null; } return false; } /** * Mark the order as completed * @throws Exception If the order is not selected * @throws Exception If the order is already completed */ public function markAsCompleted(string|null $operator = null): void { self::requireSelected(); // Check if the order is already completed if ($this->completed_at->value() !== null) { throw new Exception('Order is already completed'); } // Set the completed_at property to the current timestamp $this->completed_at->set(date('Y-m-d H:i:s')); $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(); } } /** * Get the order by invoice id * @param int $invoiceId * @return $this * @throws Exception */ public function getOrderByInvoiceId(int $invoiceId): orders_o { global $db; $sql = "SELECT id FROM economic_module_orders WHERE invoice_id = $invoiceId"; $result = $db->query($sql); if ($result->num_rows > 0) { $row = $result->fetch_assoc(); return $this->getOrderById($row['id']); } throw new Exception('Order not found'); } /** * Set the stripe invoicing for an order * @throws Exception */ public function setStripeInvoicing(string $invoice_id, string $stripe_customer_id, string $url): void { self::requireSelected(); $this->stripe_module_orders->add($this->id, $invoice_id, $stripe_customer_id, $url); 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; try { // Avoid SQL injection $order_array = array_map(function ($value) use ($db) { if (is_string($value)) { // Escape string values return $db->escape_string($value); } // Return other types as is return $value; }, $order_array); // Create a new record in the database $this->id = self::add_object([...$order_array]); $this->getObjectProperties(); self::requireSelected(); self::assignToInvoiceCollection(); return $this; } catch (Exception $e) { $response->error($e->getMessage()); } } public function add(int $customer_id, int $cashier_id, string $reference, string $notes, int $department_id, string $reg_1 = '', string $reg_2 = '', string $reg_3 = ''): orders_o { global $db, $response; try { // Avoid SQL injection $reference = $db->escape_string($reference); $notes = $db->escape_string($notes); $reg_1 = $db->escape_string($reg_1); $reg_2 = $db->escape_string($reg_2); $reg_3 = $db->escape_string($reg_3); // Create a new record in the database $sql = "INSERT INTO $this->table (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3) VALUES ($customer_id, $cashier_id, '$reference', '$notes', $department_id, '$reg_1', '$reg_2', '$reg_3')"; $db->query($sql); // Get the id of the new record $this->id = $db->insert_id(); // Set the values of the object properties $this->getObjectProperties(); self::requireSelected(); self::assignToInvoiceCollection(); return $this; } catch (Exception $e) { $response->error($e->getMessage()); } } /** * Assign the order to an invoice collection * @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, 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(); if ($customer_id === 0) { throw new Exception('The customer id is not set for the order!.'); } $customer->getUserByCustomerNumber($customer_id); $customer->requireSelected(); // Get the invoice collection id $invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId(); // Assign the order to the invoice collection $this->invoice_collection_id->set($invoiceCollectionId); if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) { $previousCollection = new collected_order_invoices_o(); $previousCollection->select($previousInvoiceCollectionId); if ($previousCollection->exists()) { $previousCollection->objectChanged(); } } if ($notifyChanges) { self::objectChanged(); } } /** * Get the recommended order items, based on the order history, vehicle plate and department * @return array The recommended order items * @throws Exception If the order is not selected */ public function getRecommendedOrder(): array { self::requireSelected(); $recommended = [ 'reg_1' => self::getRecommendedOrderPlate($this->reg_1->value()), 'reg_2' => [], 'reg_3' => [], ]; return $recommended; } /** * @throws Exception If the order is not selected */ public function getRecommendedOrderPlate(string $plate): array { self::requireSelected(); $result = [ 'order_history' => self::get_vehicle_last_orders_items($plate), ]; // If the MotorApi is enabled, get the recommended order items based on the vehicle plate $MotorApi = new motorapi(); if ($MotorApi->config->enabled->isTrue()) { $MotorApi_data = $MotorApi->getRecommendedProducts($plate); if ($MotorApi_data) { // Get the recommended order items based on the vehicle plate $result['motorapi'] = $MotorApi_data; } } return $result; } /** * Get the last orders item ids for a vehicle plate * @param string $plate The vehicle plate * @return array The last order item ids */ public function get_vehicle_last_orders_items(string $plate): array { 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); $order_items = [ 1 => [], 2 => [], 3 => [], 4 => [], 5 => [], ]; // Get the order item (ids) from the last 5 orders $index = 0; foreach ( $orders as $order ) { $index++; $order_tmp = new orders_o(); // Get the order items for the order $items_tmp = $order_tmp->getOrderItems($order['id']); // Add the items to the result foreach ( $items_tmp as $item ) { $order_items[$index][] = [ 'product_id' => $item['product_id'], 'reference' => $item['reference'], 'quantity' => $item['quantity'], 'product' => $item['product'], ]; } } return $order_items; } public function getOrderItems(int $order_id): array { global $db; $sql = "SELECT * FROM order_items WHERE order_id = $order_id"; $result = $db->query($sql); $order_items = []; if ($result->num_rows > 0 && $result) { while ($row = $result->fetch_assoc()) { $order_item = new order_items_o(); $order_items[] = $order_item->getOrderItemById($row['id'])->getItemAsArray(); } } return $order_items; } /** * Get the order history for a vehicle plate * @param string $plate The vehicle plate * @return array The order history */ public function get_vehicle_order_history(string $plate): array { global $db; $stmt = $db->prepare("SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?) AND deleted_at IS NULL ORDER BY id DESC LIMIT 5"); if (!$stmt) { return []; } $stmt->bind_param('sss', $plate, $plate, $plate); $stmt->execute(); $result = $stmt->get_result(); $stmt->close(); return $db->fetch_all($result); } /** * @throws Exception If the order is not selected */ public function getIncludeInInvoiceCount(): int { self::requireSelected(); $order_items = new order_items_o(); $items = $order_items->getAllItemsAsArray($this->id, [ 'include_in_invoice', ]); $count = 0; foreach ( $items as $item ) { if ($item['include_in_invoice']) { $count++; } } return $count; } /** * Get the wash subscription transactions for a customer * @throws Exception If something goes wrong */ public function getWashSubscriptionTransactions(int $customer_number, bool $asArray = true, ?string $dateFrom = null, ?string $dateTo = null): array { return self::listObjectsWithPagination( 1, 100, null, [ 'customer_id' => $customer_number, 'deleted_at' => null, 'cashier_id' => (new collected_order_invoices_o())->economic_wash_subscription_user_id, ...(!empty($dateFrom) ? ['created_at-date_from' => $dateFrom] : []), ...(!empty($dateTo) ? ['created_at-date_to' => $dateTo] : []), ], [ 'id' => 'DESC', ], function ($object) use ($asArray) { $res = (new orders_o())->select($object['id']); if ($asArray) { return $res->asArray(); } return $res; } ); } /** * @param bool $cacheResult Whether to cache the result or not, this is used to prevent caching simulated orders * @param bool $skipCache Whether to skip the cache or not, this is used to force a fresh result * Convert the order object to an array * @return array The order as an array * @throws Exception */ public function asArray(bool $skipCache = false, bool $cacheResult = true): array { self::requireSelected(); if (!$skipCache) { // Check if the object is cached $cached = self::getCached('asArray', $this->id); if ($cached) { return (array)$cached; } } $tmp = [ 'id' => $this->id, 'customer_id' => (int)$this->customer_id->value(), 'cashier_id' => (int)$this->cashier_id->value(), 'reference' => $this->reference->value(), 'notes' => $this->notes->value(), 'department_id' => (int)$this->department_id->value(), 'reg_1' => $this->reg_1->value(), 'reg_2' => $this->reg_2->value(), '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(), 'booking_id' => (int)$this->booking_id->value(), '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(), ]; // Cache the object if ($cacheResult) { self::cache('asArray', $tmp, $this->id); self::setCachedExpiration('asArray', self::$asArrayCacheExpiration, $this->id); } return $tmp; } public function getNetAmount(): float { self::requireSelected(); $net = 0; // Get the order items object $order_items = new order_items_o(); // Get the price, quantity of the order items $items = $order_items->getAllItemsAsArray($this->id, [ 'include_in_invoice', 'price', 'quantity', ]); // Loop through the items and get the net amount foreach ( $items as $item ) { // Check if the item is included in the invoice if (!$item['include_in_invoice']) { continue; } $tmp_price = (int)$item['price']; $tmp_quantity = (int)$item['quantity']; // Add the price to the net amount $net += $tmp_price * $tmp_quantity; } return $net; } /** * Get the net amount of the orders * @param int[] $order_ids An array of order IDs to get the net amount for * @return array An array with the net amount for each order [ order_id => net_amount ] * @throws Exception If the order is not selected */ public function getNetAmountForOrders(array $order_ids): array { if (empty($order_ids)) { return []; } $order_items = new order_items_o(); $tmp = $order_items->getFieldsWhere( [ 'order_id' => $order_ids, 'include_in_invoice' => true, ], [ 'price', 'quantity', 'include_in_invoice', 'order_id' ] ); $net_amounts = []; foreach ( $tmp as $item ) { $net_amounts[$item['order_id']] = (float)(($net_amounts[$item['order_id']] ?? 0) + ((int)$item['price'] * (int)$item['quantity'])); } return $net_amounts; } public function includeIncludes(): orders_o { global /** @var response $response */ $response; $includeEverything = $response->getRequestParameter('include_all') === 'true'; /** orderItems */ if ($response->getRequestParameter('includeOrderItems') || $includeEverything) { $response->add_include('orderItems', $this->applyDepartmentPrices($this->getOrderItems($this->id), (int)$this->department_id->value())); } /** customer */ if ($response->getRequestParameter('includeCustomer') || $includeEverything) { $customer = new users_o(); $response->add_include('customer', $customer->getUserByCustomerNumber((int)$this->customer_id->value())->includeIncludes()->asArray()); } /** cashier */ if ($response->getRequestParameter('includeCashier') || $includeEverything) { $cashier = new users_o(); $response->add_include('cashier', $cashier->getUserById($this->cashier_id->value())->asArray()); } /** * economicModuleOrders */ if ($response->getRequestParameter('includeEconomicModuleOrders') || $includeEverything) { $response->add_include('economicModuleOrders', $this->economic_module_orders->getByOrderId($this->id)->asArray()); } /** * stripeModuleOrders */ if ($response->getRequestParameter('includeStripeModuleOrders') || $includeEverything) { $response->add_include('stripeModuleOrders', $this->stripe_module_orders->exists() ? $this->stripe_module_orders->asArray() : []); } return $this; } /** * Apply department pricing to a list of products * @param array $order_items * @param int $department_id * @return array */ public function applyDepartmentPrices(array $order_items, int $department_id): array { global $response; $department = new departments_o(); $department->getDepartmentById($department_id); $department->getDepartmentProductPrices($department_id); foreach ( $order_items as $key => $order_item ) { $product = new products_o(); $product->getProductById($order_item['product_id']); $order_items[$key]['product']['price'] = $product->getDepartmentPrice($department_id); } return $order_items; } public function isPlateSeenBefore(string $reg_1): bool { // Check if the plate has been seen before $count = self::getFieldsWhere([ 'reg_1' => $reg_1, 'deleted_at' => null, ], [ 'reg_1', ]); return (bool)$count; } public function getFixedPricingTransactions(int $customer_number, false $asArray, string|null $dateFrom = null, string|null $dateTo = null): array { // Get the fixed pricing transactions for a customer return self::listObjectsWithPagination( 1, 100, null, [ 'customer_id' => $customer_number, 'deleted_at' => null, 'cashier_id' => (new collected_order_invoices_o())->economic_wash_subscription_user_id, ...(!empty($dateFrom) ? ['created_at-date_from' => $dateFrom] : []), ...(!empty($dateTo) ? ['created_at-date_to' => $dateTo] : []), ], [ 'id' => 'DESC', ], function ($object) use ($asArray) { $res = (new orders_o())->select($object['id']); if ($asArray) { return $res->asArray(); } return $res; } ); } /** * @throws Exception */ public function getTankCleaningTransactions(int $customer_number, false $asArray, string $dateFrom, string $dateTo): array { // Get the tank cleaning transactions for a customer return self::listObjectsWithPagination( 1, 100, null, [ 'customer_id' => $customer_number, 'deleted_at' => null, ...(!empty($dateFrom) ? ['created_at-date_from' => $dateFrom] : []), ...(!empty($dateTo) ? ['created_at-date_to' => $dateTo] : []), ], [ 'id' => 'DESC', ], function ($object) use ($asArray) { $res = (new orders_o())->select($object['id']); if ($asArray) { return $res->asArray(); } return $res; } ); } /** * @throws Exception */ public function isBooked(bool $useCache = false): bool { $this->requireSelected(); // Check if we should use the cached value if ($useCache) { $cached = self::getCached('isBooked', $this->id); if ($cached !== null) { return (bool)$cached; } } // Check the invoice collection has been booked if ((int)$this->invoice_collection_id->value() > 0) { $invoice_collection = (new collected_order_invoices_o())->select((int)$this->invoice_collection_id->value()); self::requireSelected(); $isBooked = $invoice_collection->isBooked(); // Cache the result self::cache('isBooked', $isBooked, $this->id); self::setCachedExpiration('isBooked', self::$isBookedCacheExpiration, $this->id); return $isBooked; } else { // If there is no invoice collection, the order is not booked self::cache('isBooked', false, $this->id); self::setCachedExpiration('isBooked', self::$isBookedCacheExpiration, $this->id); return false; } } /** * Select an order by its wash ID * @param int|string|null $WashId The wash ID to select the order by * @return orders_o|null The selected order object or null if no order is found * @throws Exception */ public function selectByWashId(int|string|null $WashId): orders_o|null { $result = self::getFieldsWhere([ 'wash_id' => $WashId, 'deleted_at' => null, ], [ 'id' ]); if (empty($result)) { return null; // No order found with the given wash ID } $this->select((int)$result[0]['id']); return $this; } /** * Select an order by its registration number and date range (This is used to find potential duplicate orders) * @param int|string|null $RegistrationNumber The registration number to select the order by * @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format * @param string $dateTo The end date of the date range (inclusive) "Y-m-d H:i:s" format * @return orders_o|null The selected order object or null if no order is found * @throws Exception If the select fails * @see xlvask_usage_log::getPotentialOrder() */ public function selectByRegistrationNumberAndDateRange(int|string|null $RegistrationNumber, string $dateFrom, string $dateTo): orders_o|null { global $db; // Validate the date range 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'); } // Ensure the registration number is a string $RegistrationNumber = (string)$RegistrationNumber; if (empty($RegistrationNumber)) { throw new Exception('Registration number cannot be empty'); } // Prepare the SQL query to find the order by registration number and date range $RegistrationNumber = $db->escape_string($RegistrationNumber); $dateFrom = $db->escape_string($dateFrom); $dateTo = $db->escape_string($dateTo); // Select the order by registration number and date range $sql = "SELECT id FROM $this->table WHERE (reg_1 = '$RegistrationNumber' OR reg_2 = '$RegistrationNumber' OR reg_3 = '$RegistrationNumber') AND created_at BETWEEN '$dateFrom' AND '$dateTo' AND deleted_at IS NULL"; $result = $db->query($sql); if ($result->num_rows > 0) { $row = $result->fetch_assoc(); return $this->select((int)$row['id']); } return null; // No order found with the given registration number and date range } /** * Add a new XL Vask order * @param users_o $user The user who is placing the order (Billing customer) * @param xlvask_usage_log $xlvask_usage_log The XL Vask usage log containing the wash items, department, and registration number * @return orders_o The created order object * @throws Exception If the order is not selected, or if the user or department is not valid */ public function addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log): orders_o { // Add a new order for XL Vask $this->add( (int)$user->customer_number->value(), 2285, (string)(new customer_vehicles_o())->getPlateReferenceIfExists($xlvask_usage_log->RegistrationNumber), '', // No notes for XL Vask orders (int)$xlvask_usage_log->getDepartment()->id, $xlvask_usage_log->RegistrationNumber, ); // Set the wash ID for the order $this->wash_id->set($xlvask_usage_log->WashId); // Set the lane used for the order (if applicable) $this->lane->set($xlvask_usage_log->getLane()); // Set the timestamp for the order creation to the time the wash was performed $this->created_at->set($xlvask_usage_log->getTimestampStart()); // Add the products to the order $firstItemId = null; /** @var xlvask_wash_item $washItem */ foreach ( $xlvask_usage_log->WashItems as $washItem ) { // Check if the item should be included in the order // Check if the product is with id 64 (irrelevant product, a bi product of the wash) if (!$washItem->shouldIncludeInOrder() || $washItem->getProduct($xlvask_usage_log)->id === 64) { continue; // Skip items that should not be included in the order } $order_item = new order_items_o(); $order_item->add( $this->id, $washItem->getProduct($xlvask_usage_log)->id, '', '', //$washItem->OriginalProductName, 2285, (int)((float)$washItem->getUnitPriceExVat() * (float)$washItem->Count), (int)1, $firstItemId === null ? null : $firstItemId, // Set the first item as the parent item (if applicable) ); // Set the first item ID for the next item to link to (provided this is the first item) if ($firstItemId === null) { $firstItemId = (int)$order_item->id; } } // Validate the order matches the desired total $total = $this->getNetAmount(); if ($total !== $xlvask_usage_log->getTotalPrice()) { echo "Order ID: " . $this->id . "\n"; echo "Customer: " . $user->customer_number->value() . "\n"; echo "Registration Number: " . $xlvask_usage_log->RegistrationNumber . "\n"; echo "Wash Items: \n"; foreach ( $xlvask_usage_log->WashItems as $item ) { echo "- " . $item->getProduct($xlvask_usage_log)->name . " (Count: " . $item->Count . ", Price: " . $item->getUnitPriceExVat() . ")\n"; } echo "Expected Total Price: " . $xlvask_usage_log->getTotalPrice() . "\n"; echo "Actual Total Price: $total\n"; echo "Order Items: \n"; foreach ( $this->getOrderItems($this->id) as $order_item ) { echo "- " . $order_item['product']['name'] . " (Count: " . $order_item['quantity'] . ", Price: " . $order_item['product']['price'] . ")\n"; } echo "Total: $total, Expected: " . $xlvask_usage_log->getTotalPrice() . "\n"; throw new Exception('The total amount of the order does not match the expected total. Expected: ' . $xlvask_usage_log->getTotalPrice() . ', Actual: ' . $total); } // Save the order $this->objectChanged(); // Return the order object return $this; } /** * Generate a fake order from a usage log - Used to preview the order before it is created * @param xlvask_usage_log $xlvask_usage_log The usage log to generate the order from * @param bool $includeItems Whether to include the order items in the simulated order, together with the total net amount * @returns array['order' => array, 'order_items' => array] * @throws Exception */ public function simulateOrderFromXLVask(xlvask_usage_log $xlvask_usage_log, bool $includeItems = true): array { $this->id = self::generateFakeId(); // Set a negative ID to indicate this is a simulated order $this->getObjectProperties(); $this->customer_id->set(($xlvask_usage_log->hasBillableCustomer() ? (int)$xlvask_usage_log->CustomerId : 12345679)); // Set the customer ID from the usage log, or 0 if not billable $this->cashier_id->set(2285); // Set the cashier ID to a default value for simulation $this->reference->set((new customer_vehicles_o())->getPlateReferenceIfExists((string)$xlvask_usage_log->RegistrationNumber)); // Set the reference to the vehicle plate reference (If it exists) $this->notes->set(''); // Set notes to an empty string for simulation $this->department_id->set($xlvask_usage_log->getDepartment()->id); // Set a default department ID for simulation $this->reg_1->set((string)$xlvask_usage_log->RegistrationNumber); // Set the registration number from the usage log $this->reg_2->set(''); $this->reg_3->set(''); $this->completed_at->set(null); // Set completed_at to null for simulation $this->created_at->set($xlvask_usage_log->getTimestampStart()); // Set the created_at to the start time of the usage log $this->deleted_at->set(null); // Set deleted_at to null for simulation $this->invoice_collection_id->set(null); // Set invoice_collection_id to null for simulation $this->booking_id->set(null); // Set booking_id to null for simulation $this->wash_id->set($xlvask_usage_log->WashId); // Set the wash ID from the usage log $this->lane->set($xlvask_usage_log->getLane()); // Set lane to null for simulation if ($includeItems) { // If we are including items, simulate the order items from the usage log $order_items = $this->simulateOrderItemsFromXLVask($xlvask_usage_log); // Save the total net amount for the simulated order (this is the sum of all item prices) $this->setTemporaryNetAmount( array_sum(array_map(function ($item) { return (int)$item['price'] * (int)$item['quantity']; }, $order_items)) ); } else { $order_items = []; // No items included in the simulated order $this->setTemporaryNetAmount(0); // Set the temporary net amount to 0 } return [ 'order' => $this->asArray(true, false), // Return the simulated order as an array 'order_items' => $order_items, // Return the simulated order items ]; } /** * Get a list of customers who have placed orders within a specific date range * @param string $dateFrom (E.g. "2023-01-01 00:00:00") * @param string $dateTo (E.g. "2023-01-31 23:59:59") * @return users_o[] * @throws Exception */ public function getCustomersWithOrdersInDateRange(string $dateFrom, string $dateTo): array { global $db; // Validate the date range 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'); } // Prepare the SQL query to find customers with orders in the date range (unique customer IDs) $dateFrom = $db->escape_string($dateFrom); $dateTo = $db->escape_string($dateTo); $sql = "SELECT DISTINCT customer_id FROM $this->table WHERE created_at BETWEEN '$dateFrom' AND '$dateTo' AND deleted_at IS NULL"; $result = $db->query($sql); if ($result->num_rows === 0) { return []; // No customers found in the date range } // Fetch all $result = $db->fetch_all($result); // Get the customers by their customer numbers return (new users_o())->getUsersByCustomerNumbers( array_map(function ($row) { return (int)$row['customer_id']; }, $result) ); } /** * Get transactions for a specific customer within a date range * @param int $customerNumber The customer number to filter by * @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format * @param string $dateTo The end date of the date range (inclusive) "Y-m-d H:i:s" format * @return orders_o[] The transactions for the customer within the specified date range * @throws Exception If the date range is invalid or if no transactions are found */ public function getTransactionsForCustomer(int $customerNumber, string $dateFrom, string $dateTo): array { global $db; // Validate the date range 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'); } // Prepare the SQL query to find transactions for the customer in the date range $dateFrom = $db->escape_string($dateFrom); $dateTo = $db->escape_string($dateTo); $sql = "SELECT id FROM $this->table WHERE customer_id = $customerNumber AND created_at BETWEEN '$dateFrom' AND '$dateTo' AND deleted_at IS NULL"; $result = $db->query($sql); if ($result->num_rows === 0) { return []; // No transactions found for the customer in the date range } $transactions = []; while ($row = $result->fetch_assoc()) { $order = new orders_o(); $order->select((int)$row['id']); $transactions[] = $order; } return $transactions; } /** * Get transactions for customers in a date range * @param int[] $customers An array of customer numbers to filter by * @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format * @param string $dateTo The end date of the date range (inclusive) "Y-m-d H:i:s" format * @return array[customer_number => orders_o[]] The transactions for each customer within the specified date range * @throws Exception If the date range is invalid or if no transactions are found */ 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'); } if (strtotime($dateFrom) > strtotime($dateTo)) { throw new Exception('The start date cannot be after the end date'); } // Prepare the SQL query to find transactions for the customers in the date range $dateFrom = $db->escape_string($dateFrom); $dateTo = $db->escape_string($dateTo); $customerNumbers = implode(',', array_map('intval', $customers)); $sql = "SELECT id, customer_id FROM $this->table WHERE customer_id IN ($customerNumbers) AND created_at BETWEEN '$dateFrom' AND '$dateTo' AND deleted_at IS NULL"; $result = $db->query($sql); if ($result->num_rows === 0) { return []; // No transactions found for the customers in the date range } $transactions = []; while ($row = $result->fetch_assoc()) { $order = new orders_o(); $order->select((int)$row['id']); $transactions[(int)$row['customer_id']][] = $order; } 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>> * @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 * @param string $dateTo The end date of the date range (inclusive) "Y-m-d H:i:s" format * @return array An array of possible duplicate orders, keyed by registration number. * @throws Exception */ public function getOrdersWithPossibleDuplicates(string $dateFrom, string $dateTo): array { global $db; // Validate the date range 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'); } // Prepare the SQL query to find orders with possible duplicates in the date range $dateFrom = $db->escape_string($dateFrom); $dateTo = $db->escape_string($dateTo); $sql = "SELECT id, customer_id, reg_1, reg_2, reg_3, created_at FROM $this->table WHERE created_at BETWEEN '$dateFrom' AND '$dateTo' AND deleted_at IS NULL"; $result = $db->query($sql); if ($result->num_rows === 0) { return []; // No orders found in the date range } $orders = []; while ($row = $result->fetch_assoc()) { if (empty($row['reg_1'])) { continue; // Skip orders without a registration number } // Prepare the order data $tmp = [ 'id' => (int)$row['id'], 'reg_1' => (string)$row['reg_1'], 'reg_2' => (string)$row['reg_2'] ?? '', 'reg_3' => (string)$row['reg_3'] ?? '', 'created_at' => (string)$row['created_at'], ]; // Add the order to the list $orders[$tmp['reg_1']][] = [ 'id' => $tmp['id'], 'reg_1' => $tmp['reg_1'], 'reg_2' => (string)$row['reg_2'] ?? '', 'reg_3' => (string)$row['reg_3'] ?? '', 'created_at' => $tmp['created_at'], 'object' => (new orders_o())->select((int)$tmp['id']) ]; } return invoicing_period_utils::filterPossibleDuplicates($orders, 86400); } public function setTemporaryNetAmount(float $amount): void { // Set a temporary net amount for the order $this->temporary_net_amount = $amount; } /** * @param xlvask_usage_log $xlvask_usage_log * @return array * @throws Exception */ public function simulateOrderItemsFromXLVask(xlvask_usage_log $xlvask_usage_log): array { // Add the products to the simulated order $firstItemId = null; // Initialize the first item ID to null $current_item_id = null; $order_items = []; // Initialize the order items array /** @var xlvask_wash_item $washItem */ foreach ( $xlvask_usage_log->WashItems as $washItem ) { // Check if the item should be included in the order // Check if the product is with id 64 (irrelevant product, a bi product of the wash) $product = $washItem->getProduct($xlvask_usage_log); if (!$washItem->shouldIncludeInOrder() || $product->id === 64) { unset($washItem); continue; // Skip items that should not be included in the order } $order_item = new order_items_o(); $order_item->id = self::generateFakeId(); // Set a negative ID to indicate this is a simulated order item // If the id is positive, forcefully quit if ($order_item->id > 0) { throw new Exception('The order item ID must be negative for simulation'); } $order_item->getObjectProperties(); $order_item->order_id->set($this->id); // Set the order ID to the simulated order ID $order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item $order_item->reference->set(''); // Get the product price based on the department $priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value()); $product_price = (int)$priceResolution['price']; // Get the department price for the product // Get the customers custom price discount percentage $user = $xlvask_usage_log->getUser(); // Get the user from the usage log if (!$user->exists()) { throw new Exception('No user found matching the customer number in the usage log'); } if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { $product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value()); } $order_item->notes->set(null); // Set notes for the simulated order item $order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage $order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item $order_item->related_item_id->set($firstItemId === null ? null : $firstItemId); // Set the related item ID to the first item ID (if applicable) $order_item->include_in_invoice->set(true); // Set include_in_invoice to true for the simulated order item $order_item->cashier_id->set($this->cashier_id->value()); // Set the cashier ID to the simulated order's cashier ID // Set the first item ID for the next item to link to (provided this is the first item) if ($firstItemId === null) { $firstItemId = $order_item->id; // Set the first item ID to the current item ID } $order_items[] = $order_item->getItemAsArray(); // Add the simulated order item to the order items array // clear up memory unset($order_item); } return $order_items; } public function getOrderPotentialDuplicatesIfFound(string $reg_1, string $reg_2, string $reg_3, int $department_id, string $created_at): array { global /** @var db $db */ $db; // Validate the registration numbers $reg_1 = $db->escape_string($reg_1); $reg_2 = $db->escape_string($reg_2); $reg_3 = $db->escape_string($reg_3); // Strip any whitespace from the registration numbers $reg_1 = trim($reg_1); $reg_2 = trim($reg_2); $reg_3 = trim($reg_3); // Created at must be between: $created_at_from = date('Y-m-d H:i:s', strtotime($created_at . ' - 24 hours')); $created_at_to = date('Y-m-d H:i:s', strtotime($created_at . ' + 24 hours')); // Validate the department ID if ($department_id <= 0) { throw new Exception('Invalid department ID provided'); } if (empty($reg_1)) { return []; // No registration number provided, return empty array } // Select the unique order ids that match the registration numbers and department ID within the 24 hour range $sql = "SELECT id FROM $this->table WHERE (reg_1 = '$reg_1') AND department_id = $department_id AND created_at BETWEEN '$created_at_from' AND '$created_at_to' AND deleted_at IS NULL"; //$sql = "SELECT id FROM $this->table WHERE (reg_1 = '$reg_1' AND reg_2 = '$reg_2' AND reg_3 = '$reg_3') AND department_id = $department_id AND created_at BETWEEN '$created_at_from' AND '$created_at_to' AND deleted_at IS NULL"; // Execute the query $result = $db->query($sql); if ($result->num_rows === 0) { return []; // No potential duplicates found } // Fetch all potential duplicates $result = $db->fetch_all($result); return array_map(function ($row) { $order = new orders_o(); $order->select((int)$row['id']); return [...$order->asArray(), 'order_items' => $order->getOrderItems($order->id)]; // Include order items in the result }, $result); // Return the potential duplicates as an array of order arrays } public function getCustomerProductPrice(products_o $product): int { self::requireSelected(); // Get the current user $current_user = $this->getCustomer(); // Get the product price for the current customer, taking into account any custom pricing if (!$current_user->exists()) { throw new Exception('No current user found'); } $priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value()); $price = (int)$priceResolution['price']; // Get the department price for the product if (products_o::priceResolutionIsCustomMissing($priceResolution)) { return $price; } return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value()); } /** * @throws Exception */ public function getCustomer(): users_o { self::requireSelected(); $user = new users_o(); $user->getUserByCustomerNumber((int)$this->customer_id->value()); return $user; } /** * Set or unset the pending handheld cache indicator for the order * This indicator is used to mark orders that are pending handheld processing * The indicator is stored in the cache and expires at midnight * @param bool $isPending Whether to set (true) or unset (false) the pending handheld indicator * @throws Exception If the order is not selected */ public function setPendingHandheldIndicator(bool $isPending = true): void { self::requireSelected(); // Set or unset the pending handheld cache indicator for the order $cache_key = 'pending_handheld_cache_indicator'; if ($isPending) { // Get the amount of seconds until midnight $secondsUntilMidnight = (int)(strtotime('tomorrow') - time()); $this->cache($cache_key, 1); $this->setCachedExpiration($cache_key, $secondsUntilMidnight); $this->using_hand_held->set(true); // Mark the order as using handheld } else { $this->deleteCached($cache_key); } } /** * Get whether the order is marked as pending handheld processing * @return bool True if the order is marked as pending handheld, false otherwise * @throws Exception If the order is not selected */ public function isPendingHandheld(): bool { self::requireSelected(); // Check if the pending handheld cache indicator is set for the order $cache_key = 'pending_handheld_cache_indicator'; return $this->getCached($cache_key) === 1; } /** * @throws Exception */ public function getNetAmountForOrderItemsOriginal(): int { // Get the original net amount for the order items, ignoring any temporary net amount set self::requireSelected(); $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; } $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] = $product->getDepartmentPriceResolution($department_id); } if ($tmp_user === null) { $tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); } $unitPrice = (int)$department_price_cache[$product_id]['price']; if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) { $unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id); } $post_discount = $unitPrice * $quantity; $total += $post_discount; } return $total; } /** * @throws Exception */ public function isOwnOrder(int $customer_id): bool { self::requireSelected(); return (int)$this->customer_id->value() === $customer_id; } /** * @throws Exception */ public function hasWashCertificateAttached(): bool { self::requireSelected(); 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 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 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(string|null $safety_seal = null, string|null $operator = null, $date = null): void { self::requireSelected(); // Avoid generating duplicate certificates if ($this->hasWashCertificateAttached()) { return; } // Get the department for the order $department = (new departments_o())->select((int)$this->department_id->value()); if (!$department->exists()) { throw new Exception('Department 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(); $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')); $time_formatted = ($date instanceof DateTime ? $date->format('H:i') : date('H:i')); // Generate PDF $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, // Used as document number on the template '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' => ($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() ); $pdf_path = $pdf_generator->generate_pdf(); // Attach PDF to the order $this->addAttachment(new attachment_content((object)[ 'document' => $pdf_path, 'other' => attachment_content::OTHER_TYPE_WASH_CERTIFICATE, ])); } /** * @throws Exception */ public function createOrderFromBooking(int $id, int $user_id): orders_o { // Create an order from a booking $booking = (new order_bookings_o())->select($id); $booking->requireSelected(); // Add the order $new_order = $this->add( (int)$booking->customer_number->value(), $user_id, (string)$booking->reference->value(), (string)$booking->note->value(), (int)$booking->department->value(), (string)$booking->reg_1->value(), (string)$booking->reg_2->value(), (string)$booking->reg_3->value(), ); // Set the PO (if any) if (!empty($booking->po->value())) { $new_order->po->set((string)$booking->po->value()); } $this->select((int)$new_order->id); return $this; } /** * If the transaction should be included in invoicing, based on the department. * Some departments have a setting to exclude them from invoicing. * @return bool * @throws Exception If the order is not selected */ 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(); } /** * If the order is linked to a booking, get the booking object * @return order_bookings_o|null The booking object if linked, null otherwise * @throws Exception */ public function getOrderBooking(): order_bookings_o|null { self::requireSelected(); if ((int)$this->booking_id->value() <= 0) { return null; } $booking = new order_bookings_o(); $booking->select((int)$this->booking_id->value()); return $booking->exists() ? $booking : null; } public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int { return (new department_wash_count_service())->countInDateRange($date_start, $date_end, $department_id); } /** * @param array $department_ids * @return array * @throws Exception */ public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array { return (new department_wash_count_service())->countByHourForDepartments($date_start, $date_end, $department_ids); } /** * @throws Exception * @retuns order_items_o[] */ public function getOrderItemObjects(): array { self::requireSelected(); $order_items = $this->getOrderItems((int)$this->id); $order_item_objects = []; foreach ( $order_items as $item ) { $order_item = new order_items_o(); $order_item->select((int)$item['id']); $order_item_objects[] = $order_item; } return $order_item_objects; } /** * @param \DateTime|string $daily_start E.g. 2025-01-01 08:00:00 * @param \DateTime|string $daily_end E.g. 2025-01-01 18:00:00 * $options array An optional array of options to filter the results. Supported options: * - department_id (int): Filter orders by a specific department ID. * @return orders_o[] An array of orders that have washes within the specified daily time range, regardless of the date. The time range is specified in "H:i:s" format (e.g. "08:00:00" for 8 AM and "18:00:00" for 6 PM). * @throws Exception */ public function getWashesInTimeRange(\DateTime|string $daily_start, \DateTime|string $daily_end, array $options = []): array { global /** @var db $db */ $db; // Convert daily_start and daily_end to time strings if they are DateTime objects if ($daily_start instanceof \DateTime) { $daily_start = $daily_start->format('Y-m-d H:i:s'); } if ($daily_end instanceof \DateTime) { $daily_end = $daily_end->format('Y-m-d H:i:s'); } // Validate the time range if (strtotime($daily_start) === false || strtotime($daily_end) === false) { throw new Exception('Invalid time range provided'); } if (strtotime($daily_start) > strtotime($daily_end)) { throw new Exception('The start time cannot be after the end time'); } // Prepare the SQL query to find orders with washes in the daily time range $daily_start = $db->escape_string($daily_start); $daily_end = $db->escape_string($daily_end); // Options for filtering by department ID $department_filter = ''; if (isset($options['department_id']) && is_int($options['department_id'])) { $department_id = $options['department_id']; $department_filter = "AND o.department_id = $department_id"; } $sql = "SELECT DISTINCT o.id FROM $this->table o JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id WHERE TIME(o.created_at) BETWEEN TIME('$daily_start') AND TIME('$daily_end') AND o.created_at BETWEEN '$daily_start' AND '$daily_end' AND o.deleted_at IS NULL AND p.is_wash = 1 $department_filter"; //echo "Executing SQL query to find washes in time range: $sql\n"; // Debug log to check the generated SQL query $result = $db->query($sql); if ($result->num_rows === 0) { return []; // No washes found in the time range } $orders = []; while ($row = $result->fetch_assoc()) { $order = new orders_o(); $order->select((int)$row['id']); $orders[] = $order; } 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; } }