2208 lines
88 KiB
PHP
2208 lines
88 KiB
PHP
<?php
|
|
|
|
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;
|
|
use classes\response;
|
|
use DateTime;
|
|
use Exception;
|
|
use helpers\xlvask_usage_log;
|
|
use helpers\xlvask_wash_item;
|
|
use routes\InvoicingPeriodRoute;
|
|
use traits\db_object_t;
|
|
|
|
class orders_o extends db
|
|
{
|
|
use db_object_t;
|
|
|
|
public object_property $customer_id;
|
|
public object_property $cashier_id;
|
|
public object_property $reference;
|
|
public object_property $notes;
|
|
public object_property $department_id;
|
|
public object_property $reg_1;
|
|
public object_property $reg_2;
|
|
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;
|
|
public stripe_module_orders_o $stripe_module_orders;
|
|
public object_property $invoice_collection_id;
|
|
public object_property $booking_id;
|
|
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
|
|
|
|
/**
|
|
* Temporary keys
|
|
* These are used for calculations and should not be stored in the database.
|
|
* @var float $temporary_net_amount
|
|
* @see InvoicingPeriodRoute::getInvoicingPeriod()
|
|
*/
|
|
public float $temporary_net_amount = 0.0; // Temporary net amount for the order, used for calculations
|
|
|
|
|
|
public function structure(): void
|
|
{
|
|
orders_schema_bootstrap::ensureTables();
|
|
$this->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<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.
|
|
*/
|
|
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<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
|
|
* @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
|
|
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // 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');
|
|
}
|
|
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
|
|
// Apply the discount percentage to the product price
|
|
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
|
|
$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');
|
|
}
|
|
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
|
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
|
|
// Apply the discount percentage to the product price
|
|
// Apply the discount percentage to the product price
|
|
return (int)round($price * (1 - ($discount_percentage / 100)));
|
|
}
|
|
|
|
/**
|
|
* @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] = (int)$product->getDepartmentPrice($department_id);
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
global /** @var db $db */
|
|
$db;
|
|
// Validate the date range
|
|
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');
|
|
}
|
|
// Prepare the SQL query to count washes in the date range for the department
|
|
$date_start = $db->escape_string($date_start);
|
|
$date_end = $db->escape_string($date_end);
|
|
// Get the amount of orders with at least one order item that has a product with the is_wash column set to true
|
|
$sql = "SELECT 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 = $department_id
|
|
AND o.created_at BETWEEN '$date_start' AND '$date_end'
|
|
AND o.deleted_at IS NULL
|
|
AND p.is_wash = 1";
|
|
$result = $db->query($sql);
|
|
if ($result->num_rows === 0) {
|
|
return 0; // No washes found in the date range
|
|
}
|
|
$row = $result->fetch_assoc();
|
|
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[]
|
|
*/
|
|
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;
|
|
}
|
|
}
|