Merge origin/master and resolve orders_o conflict
This commit is contained in:
@@ -426,6 +426,7 @@ class collected_order_invoices_o extends db
|
||||
if (empty($this->customer_number->value())) {
|
||||
throw new Exception('Customer number is not set');
|
||||
}
|
||||
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||
if (!$ignore_closed) {
|
||||
// Require the invoice collection to be open
|
||||
self::requireOpen();
|
||||
@@ -479,6 +480,7 @@ class collected_order_invoices_o extends db
|
||||
{
|
||||
// Require the invoice collection to be selected
|
||||
self::requireSelected();
|
||||
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||
// Check if the invoice draft already exists
|
||||
if (self::isDraftExisting() || self::isBooked()) {
|
||||
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
|
||||
@@ -506,6 +508,7 @@ class collected_order_invoices_o extends db
|
||||
}
|
||||
// Set the processor to E-conomic, if it's not already set to Stripe.
|
||||
$this->processor->set(ECONOMIC_PROCESSOR);
|
||||
$this->error_message->nullify();
|
||||
// Object changed
|
||||
self::objectChanged();
|
||||
return $this;
|
||||
@@ -544,9 +547,6 @@ class collected_order_invoices_o extends db
|
||||
$db;
|
||||
// Sanitize the input
|
||||
$customer_number = $db->escape_string($customer_number);
|
||||
if (!empty($name)) {
|
||||
$name = $db->escape_string($name);
|
||||
}
|
||||
if (!empty($notes)) {
|
||||
$notes = $db->escape_string($notes);
|
||||
}
|
||||
@@ -562,10 +562,20 @@ class collected_order_invoices_o extends db
|
||||
}
|
||||
// Require the customer number to be of a valid customer
|
||||
self::requireValidCustomer($customer_number);
|
||||
$resolved_name = is_string($name) ? trim($name) : '';
|
||||
if ($resolved_name === '') {
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$customer_number);
|
||||
$customer->requireSelected();
|
||||
$resolved_name = trim((string)$customer->display_name->value());
|
||||
}
|
||||
if ($resolved_name === '') {
|
||||
$resolved_name = 'Invoice collection ' . (string)$customer_number;
|
||||
}
|
||||
$resolved_name = $db->escape_string($resolved_name);
|
||||
// Add the object
|
||||
$tmp_id = self::add_object([
|
||||
'customer_number' => (int)$customer_number,
|
||||
'name' => $name,
|
||||
'name' => $resolved_name,
|
||||
'notes' => $notes,
|
||||
...(!empty($closed_at) ? ['closed_at' => (string)$closed_at] : []),
|
||||
]);
|
||||
@@ -1023,6 +1033,207 @@ class collected_order_invoices_o extends db
|
||||
$this->objectChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Split this invoice collection into one collection per order month.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function splitByOrderMonth(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
self::requireSelected();
|
||||
$this->requireCanSplitByOrderMonth();
|
||||
|
||||
$orders_by_month = $this->getIncludedOrdersGroupedByCreatedMonth();
|
||||
$preview = $this->buildSplitByOrderMonthPreview($orders_by_month);
|
||||
if (($preview['status'] ?? '') === 'skipped') {
|
||||
$preview['preview'] = false;
|
||||
return $preview;
|
||||
}
|
||||
|
||||
$original_invoice_collection_id = (int)$this->id;
|
||||
$created_invoice_collection_ids = [];
|
||||
$month_collection_ids = [];
|
||||
$months = array_keys($orders_by_month);
|
||||
$month_results = $preview['months'];
|
||||
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
foreach ( $months as $index => $month ) {
|
||||
$month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01');
|
||||
$month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01');
|
||||
if ($index === 0) {
|
||||
$month_collection = $this;
|
||||
$month_collection->created_at->set($month_timestamp);
|
||||
$month_collection->closed_at->set($month_closed_at);
|
||||
} else {
|
||||
$month_collection = (new collected_order_invoices_o())->add(
|
||||
(int)$this->customer_number->value(),
|
||||
$this->name->value(),
|
||||
$this->notes->value(),
|
||||
null,
|
||||
$month_closed_at
|
||||
);
|
||||
$month_collection->created_at->set($month_timestamp);
|
||||
$created_invoice_collection_ids[] = (int)$month_collection->id;
|
||||
}
|
||||
|
||||
$month_collection_ids[$month] = (int)$month_collection->id;
|
||||
$month_results[$index]['invoice_collection_id'] = (int)$month_collection->id;
|
||||
$month_results[$index]['target_invoice_collection_id'] = (int)$month_collection->id;
|
||||
}
|
||||
|
||||
foreach ( $orders_by_month as $month => $orders ) {
|
||||
$target_invoice_collection_id = (int)$month_collection_ids[$month];
|
||||
foreach ( $orders as $order ) {
|
||||
if ((int)$order->invoice_collection_id->value() === $target_invoice_collection_id) {
|
||||
continue;
|
||||
}
|
||||
$order->assignToInvoiceCollection($target_invoice_collection_id);
|
||||
}
|
||||
}
|
||||
|
||||
$this->objectChanged();
|
||||
foreach ( $created_invoice_collection_ids as $created_invoice_collection_id ) {
|
||||
(new collected_order_invoices_o())->select($created_invoice_collection_id)->objectChanged();
|
||||
}
|
||||
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->conn()->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'changed',
|
||||
'invoice_collection_id' => $original_invoice_collection_id,
|
||||
'preview' => false,
|
||||
'created_invoice_collection_ids' => $created_invoice_collection_ids,
|
||||
'months' => $month_results,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview how this invoice collection would be split into one collection per order month.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function previewSplitByOrderMonth(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
$this->requireCanSplitByOrderMonth();
|
||||
|
||||
return $this->buildSplitByOrderMonthPreview($this->getIncludedOrdersGroupedByCreatedMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,orders_o[]> $orders_by_month
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildSplitByOrderMonthPreview(array $orders_by_month): array
|
||||
{
|
||||
if (empty($orders_by_month)) {
|
||||
throw new Exception('No orders in invoice collection');
|
||||
}
|
||||
|
||||
ksort($orders_by_month);
|
||||
if (count($orders_by_month) < 2) {
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'already_single_month',
|
||||
'message' => 'Invoice collection already belongs to one month',
|
||||
'invoice_collection_id' => (int)$this->id,
|
||||
'preview' => true,
|
||||
'months' => array_keys($orders_by_month),
|
||||
];
|
||||
}
|
||||
|
||||
$months = [];
|
||||
foreach ( array_keys($orders_by_month) as $index => $month ) {
|
||||
$order_ids = array_map(static function (orders_o $order): int {
|
||||
return (int)$order->id;
|
||||
}, $orders_by_month[$month]);
|
||||
$month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01');
|
||||
$month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01');
|
||||
$will_create_collection = $index !== 0;
|
||||
$months[] = [
|
||||
'month' => $month,
|
||||
'invoice_collection_id' => $will_create_collection ? null : (int)$this->id,
|
||||
'target_invoice_collection_id' => $will_create_collection ? null : (int)$this->id,
|
||||
'source_invoice_collection_id' => (int)$this->id,
|
||||
'will_create_collection' => $will_create_collection,
|
||||
'order_count' => count($orders_by_month[$month]),
|
||||
'order_ids' => $order_ids,
|
||||
'created_at' => $month_timestamp,
|
||||
'closed_at' => $month_closed_at,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'changed',
|
||||
'invoice_collection_id' => (int)$this->id,
|
||||
'preview' => true,
|
||||
'created_invoice_collection_ids' => [],
|
||||
'months' => $months,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function requireCanSplitByOrderMonth(): void
|
||||
{
|
||||
self::requireSelected();
|
||||
self::requireInvoiceIsNotBooked();
|
||||
|
||||
$processor = $this->processor->value();
|
||||
$processor = $processor === null ? 0 : (int)$processor;
|
||||
if ($processor === STRIPE_PROCESSOR) {
|
||||
throw new Exception('Stripe invoice collections cannot be split');
|
||||
}
|
||||
if ($processor === OTHER_PROCESSOR) {
|
||||
throw new Exception('Due to the stateless nature of the processor, invoice collections cannot be split. Please contact technical support for assistance.');
|
||||
}
|
||||
if (!in_array($processor, [0, ECONOMIC_PROCESSOR], true)) {
|
||||
throw new Exception('Invalid processor type');
|
||||
}
|
||||
if (!empty($this->external_id->value())) {
|
||||
throw new Exception('Invoice collection already has an external invoice reference');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,orders_o[]>
|
||||
* @throws Exception
|
||||
*/
|
||||
private function getIncludedOrdersGroupedByCreatedMonth(): array
|
||||
{
|
||||
$order_ids = self::getOrderIds();
|
||||
$orders_by_month = [];
|
||||
foreach ( $order_ids as $order_id ) {
|
||||
$order = (new orders_o())->select((int)$order_id['id']);
|
||||
$order->requireSelected();
|
||||
if ($order->isBooked(true)) {
|
||||
throw new Exception('Invoice collection contains booked orders');
|
||||
}
|
||||
|
||||
$created_at = (string)$order->created_at->value();
|
||||
if (strtotime($created_at) === false) {
|
||||
throw new Exception('Order has invalid created_at date');
|
||||
}
|
||||
|
||||
$month = date('Y-m', strtotime($created_at));
|
||||
$orders_by_month[$month] = $orders_by_month[$month] ?? [];
|
||||
$orders_by_month[$month][] = $order;
|
||||
}
|
||||
|
||||
return $orders_by_month;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the vehicle subscriptions transaction to the invoice collection
|
||||
* @throws Exception If the invoice collection is not selected
|
||||
@@ -1245,6 +1456,18 @@ class collected_order_invoices_o extends db
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
private static function getLastSecondOfMonthIfEnded(string $timestamp): ?string
|
||||
{
|
||||
$date = new \DateTime($timestamp);
|
||||
$date->modify('last day of this month');
|
||||
$date->setTime(23, 59, 59);
|
||||
if ($date > new \DateTime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wash subscription price
|
||||
* @param float $price The price of the wash subscription
|
||||
@@ -1482,4 +1705,9 @@ class collected_order_invoices_o extends db
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'] === 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCachedData(): void
|
||||
{
|
||||
$this->objectChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,18 +131,8 @@ class customer_vehicles_o extends db
|
||||
private function getLastOrderId(): ?int
|
||||
{
|
||||
self::requireSelected();
|
||||
$orders_o = (new orders_o());
|
||||
$orders = $orders_o->getFieldsWhere([
|
||||
'reg_1' => (string)$this->reg->value(),
|
||||
'deleted_at' => null,
|
||||
], [
|
||||
'id',
|
||||
]);
|
||||
if (count($orders) > 0) {
|
||||
$last_order = array_pop($orders);
|
||||
return (int)$last_order['id'];
|
||||
}
|
||||
return null;
|
||||
$last_order = $this->getLastOrderByPlate((string)$this->reg->value());
|
||||
return $last_order?->id ? (int)$last_order->id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -549,4 +539,4 @@ class customer_vehicles_o extends db
|
||||
], $numberOfTransactions);
|
||||
return array_map(fn($order) => (int)$order['id'], $orders);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\department_daily_report_complaints_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
class department_daily_report_complaints_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
private const CATEGORY_VALUES = [
|
||||
'wash_quality',
|
||||
'wash_price',
|
||||
'damage_paint',
|
||||
'damage_mirrors',
|
||||
'damage_cables_electronics',
|
||||
'damage_plastic_parts',
|
||||
'damage_other',
|
||||
'service',
|
||||
'other',
|
||||
];
|
||||
|
||||
public object_property $department_id;
|
||||
public object_property $customer_number;
|
||||
public object_property $wash_date;
|
||||
public object_property $category;
|
||||
public object_property $description;
|
||||
public object_property $created_by;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
department_daily_report_complaints_schema_bootstrap::ensureTables();
|
||||
$this->setTable('department_daily_report_complaints');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false);
|
||||
$this->wash_date = new object_property($this->table, $this->id, 'wash_date', 'string', false);
|
||||
$this->category = new object_property($this->table, $this->id, 'category', 'string', false);
|
||||
$this->description = new object_property($this->table, $this->id, 'description', 'string', false);
|
||||
$this->created_by = new object_property($this->table, $this->id, 'created_by', 'int', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
// No additional cache invalidation is needed for complaint rows in v1.
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(),
|
||||
'wash_date' => $this->wash_date->value() === null ? null : (string)$this->wash_date->value(),
|
||||
'category' => $this->category->value() === null ? null : (string)$this->category->value(),
|
||||
'description' => (string)$this->description->value(),
|
||||
'created_by' => (int)$this->created_by->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
public function parseComplaint(array $complaint): array
|
||||
{
|
||||
static $department_name_cache = [];
|
||||
static $customer_name_cache = [];
|
||||
static $created_by_name_cache = [];
|
||||
|
||||
$department_id = (int)($complaint['department_id'] ?? 0);
|
||||
$customer_number = isset($complaint['customer_number']) && $complaint['customer_number'] !== null
|
||||
? (int)$complaint['customer_number']
|
||||
: null;
|
||||
$wash_date = isset($complaint['wash_date']) && $complaint['wash_date'] !== null
|
||||
? trim((string)$complaint['wash_date'])
|
||||
: null;
|
||||
$category = isset($complaint['category']) && $complaint['category'] !== null
|
||||
? trim((string)$complaint['category'])
|
||||
: null;
|
||||
$created_by = (int)($complaint['created_by'] ?? 0);
|
||||
|
||||
if (!array_key_exists($department_id, $department_name_cache)) {
|
||||
$department_name_cache[$department_id] = $department_id > 0
|
||||
? (new departments_o())->getDepartmentName($department_id)
|
||||
: null;
|
||||
}
|
||||
|
||||
if ($customer_number !== null && !array_key_exists($customer_number, $customer_name_cache)) {
|
||||
$customer_name_cache[$customer_number] = (new users_o())->getCustomerName($customer_number);
|
||||
}
|
||||
|
||||
if (!array_key_exists($created_by, $created_by_name_cache)) {
|
||||
$created_by_name_cache[$created_by] = $this->resolveCreatedByName($created_by);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)($complaint['id'] ?? 0),
|
||||
'department_id' => $department_id,
|
||||
'department_name' => $department_name_cache[$department_id] ?? null,
|
||||
'customer_number' => $customer_number,
|
||||
'customer_name' => $customer_number !== null
|
||||
? ($customer_name_cache[$customer_number] ?? null)
|
||||
: null,
|
||||
'wash_date' => $wash_date === '' ? null : $wash_date,
|
||||
'category' => $category === '' ? null : $category,
|
||||
'description' => (string)($complaint['description'] ?? ''),
|
||||
'created_by' => $created_by,
|
||||
'created_by_name' => $created_by_name_cache[$created_by] ?? null,
|
||||
'created_at' => (string)($complaint['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function parseComplaints(array $complaints): array
|
||||
{
|
||||
return array_map(fn (array $complaint): array => $this->parseComplaint($complaint), $complaints);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function addComplaint(
|
||||
int $department_id,
|
||||
?int $customer_number,
|
||||
string $wash_date,
|
||||
string $category,
|
||||
string $description,
|
||||
int $created_by
|
||||
): self
|
||||
{
|
||||
$wash_date = trim($wash_date);
|
||||
if ($wash_date === '') {
|
||||
throw new Exception('Wash date is required');
|
||||
}
|
||||
|
||||
$category = trim($category);
|
||||
if (!self::isValidCategory($category)) {
|
||||
throw new Exception('Invalid complaint category');
|
||||
}
|
||||
|
||||
$description = trim($description);
|
||||
if ($description === '') {
|
||||
throw new Exception('Description is required');
|
||||
}
|
||||
|
||||
$this->id = $this->add_object([
|
||||
'department_id' => (int)$department_id,
|
||||
'customer_number' => $customer_number === null ? null : (int)$customer_number,
|
||||
'wash_date' => $wash_date,
|
||||
'category' => $category,
|
||||
'description' => $description,
|
||||
'created_by' => (int)$created_by,
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $department_ids
|
||||
*/
|
||||
public function countForDepartmentsInRange(array $department_ids, string $date, ?string $date_to = null): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$normalized_department_ids = array_values(array_unique(array_filter(
|
||||
array_map('intval', $department_ids),
|
||||
static fn (int $department_id): bool => $department_id > 0
|
||||
)));
|
||||
|
||||
if ($normalized_department_ids === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($date_to === null) {
|
||||
$date_to = $date;
|
||||
}
|
||||
|
||||
$range_start = date('Y-m-d 00:00:00', strtotime($date));
|
||||
$range_end = date('Y-m-d 23:59:59', strtotime($date_to));
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$date_sql = $db->escape_string($date);
|
||||
$date_to_sql = $db->escape_string($date_to);
|
||||
$range_start_sql = $db->escape_string($range_start);
|
||||
$range_end_sql = $db->escape_string($range_end);
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS total
|
||||
FROM department_daily_report_complaints
|
||||
WHERE department_id IN ($department_ids_sql)
|
||||
AND (
|
||||
(wash_date IS NOT NULL AND wash_date BETWEEN '$date_sql' AND '$date_to_sql')
|
||||
OR
|
||||
(wash_date IS NULL AND created_at BETWEEN '$range_start_sql' AND '$range_end_sql')
|
||||
)"
|
||||
);
|
||||
$row = $db->fetch_assoc($result);
|
||||
|
||||
return (int)($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
public static function validCategories(): array
|
||||
{
|
||||
return self::CATEGORY_VALUES;
|
||||
}
|
||||
|
||||
public static function isValidCategory(?string $category): bool
|
||||
{
|
||||
if ($category === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(trim($category), self::CATEGORY_VALUES, true);
|
||||
}
|
||||
|
||||
private function resolveCreatedByName(int $user_id): ?string
|
||||
{
|
||||
if ($user_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = (new users_o())->select($user_id);
|
||||
if (!$user->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$display_name = trim((string)$user->display_name->value());
|
||||
return $display_name === '' ? null : $display_name;
|
||||
}
|
||||
}
|
||||
@@ -609,4 +609,231 @@ class department_daily_reports_o extends db
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array{quantity:int,products:int,earnings:int,washes:int,water_usage:int}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
|
||||
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [
|
||||
'quantity' => 0,
|
||||
'products' => 0,
|
||||
'earnings' => 0,
|
||||
'washes' => 0,
|
||||
'water_usage' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
|
||||
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
|
||||
COALESCE(SUM(oi.quantity), 0) AS products,
|
||||
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings,
|
||||
COUNT(DISTINCT CASE WHEN p.is_wash = 1 THEN o.id END) AS washes
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
LEFT 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 oi.deleted_at IS NULL";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$row = is_object($result) ? $result->fetch_assoc() : null;
|
||||
|
||||
return [
|
||||
'quantity' => (int)($row['quantity'] ?? 0),
|
||||
'products' => (int)($row['products'] ?? 0),
|
||||
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
|
||||
'washes' => (int)($row['washes'] ?? 0),
|
||||
'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array{completed:int,total:int}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getBookingSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
|
||||
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [
|
||||
'completed' => 0,
|
||||
'total' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
|
||||
$sql = "SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN order_id IS NOT NULL THEN 1 ELSE 0 END), 0) AS completed
|
||||
FROM order_bookings
|
||||
WHERE department IN ($department_ids_sql)
|
||||
AND datetime BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND deleted_at IS NULL";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$row = is_object($result) ? $result->fetch_assoc() : null;
|
||||
|
||||
return [
|
||||
'completed' => (int)($row['completed'] ?? 0),
|
||||
'total' => (int)($row['total'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @param array<int|string> $product_ids
|
||||
* @return array<int,array{product_id:int,quantity:int,out_of:int}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getProductOverviewForDepartments(string $date, array $department_ids, array $product_ids, string $date_to = null): array
|
||||
{
|
||||
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
||||
$normalized_product_ids = $this->normalizeDepartmentIds($product_ids);
|
||||
|
||||
$overview = [];
|
||||
foreach ($normalized_product_ids as $product_id) {
|
||||
$quantity = 0;
|
||||
$out_of = 0;
|
||||
|
||||
foreach ($normalized_department_ids as $department_id) {
|
||||
$quantity += $this->getProductsSoldOnDate($date, $department_id, $product_id, $date_to);
|
||||
$out_of += (int)(new departments_o())->getTotalMaxAddonsInDepartment(
|
||||
[$product_id],
|
||||
$date,
|
||||
$date_to ?? $date,
|
||||
$department_id
|
||||
);
|
||||
}
|
||||
|
||||
$overview[$product_id] = [
|
||||
'product_id' => (int)$product_id,
|
||||
'quantity' => (int)$quantity,
|
||||
'out_of' => (int)$out_of,
|
||||
];
|
||||
}
|
||||
|
||||
return $overview;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return int
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getWaterUsageForDepartments(string $date, array $department_ids, string $date_to = null): int
|
||||
{
|
||||
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
||||
$water_usage = 0;
|
||||
|
||||
foreach ($normalized_department_ids as $department_id) {
|
||||
$water_usage += $this->getTransactionsOnDateWaterUsage($date, $department_id, $date_to);
|
||||
}
|
||||
|
||||
return $water_usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array<int,array{id:int,department_id:int,created_at:string}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
|
||||
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
|
||||
$sql = "SELECT DISTINCT o.id, o.department_id, o.created_at
|
||||
FROM orders 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 oi.deleted_at IS NULL
|
||||
AND p.is_wash = 1
|
||||
ORDER BY o.created_at ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $values
|
||||
* @return array<int>
|
||||
*/
|
||||
private function normalizeDepartmentIds(array $values): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($values as $value) {
|
||||
$id = (int)$value;
|
||||
if ($id > 0) {
|
||||
$normalized[$id] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
* @throws Exception
|
||||
*/
|
||||
private function resolveDateRange(string $date, string $date_to = null): array
|
||||
{
|
||||
if ($date_to === null) {
|
||||
$date_to = $date;
|
||||
}
|
||||
|
||||
$date_start = date('Y-m-d 00:00:00', strtotime($date));
|
||||
$date_end = date('Y-m-d 23:59:59', strtotime($date_to));
|
||||
|
||||
if ($date_start === false || $date_end === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
|
||||
return [$date_start, $date_end];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\department_gate_config;
|
||||
use classes\edge_gateway_manager;
|
||||
use classes\bird;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve;
|
||||
use classes\slack;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
@@ -167,4 +169,288 @@ class department_gates_o extends db
|
||||
|
||||
return (new department_gates_o())->select((int)$exit_gate_data[0]['id']);
|
||||
}
|
||||
|
||||
public static function normalizePhoneCandidate(mixed $candidate): ?array
|
||||
{
|
||||
if (is_array($candidate)) {
|
||||
$phone = $candidate['phone_number'] ?? null;
|
||||
if ($phone !== null) {
|
||||
$country = self::extractCountryCodeFromPhoneNumber((string)$phone);
|
||||
return self::normalizePhone((string)$phone, $country);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return self::normalizePhone($candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function normalizePhone(string $raw, ?int $defaultCountryCode = 45): ?array
|
||||
{
|
||||
$trimmed = trim($raw);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
$digits = preg_replace('/\D+/', '', $trimmed);
|
||||
if (!is_string($digits) || $digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$country = $defaultCountryCode;
|
||||
$phone = $digits;
|
||||
|
||||
if (str_starts_with($trimmed, '+')) {
|
||||
$extractedCountry = self::extractCountryCodeFromPhoneNumber($trimmed);
|
||||
if ($extractedCountry !== null) {
|
||||
$country = $extractedCountry;
|
||||
$phone = substr($digits, strlen((string)$extractedCountry));
|
||||
} elseif (strlen($digits) > 8) {
|
||||
$country = (int)substr($digits, 0, 2);
|
||||
$phone = substr($digits, 2);
|
||||
}
|
||||
} elseif ($country !== null && str_starts_with($digits, (string)$country) && strlen($digits) > 8) {
|
||||
$phone = substr($digits, strlen((string)$country));
|
||||
}
|
||||
|
||||
if ($country === null || $country <= 0 || $phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$country, (int)$phone];
|
||||
}
|
||||
|
||||
public static function extractCountryCodeFromPhoneNumber(string $phone): ?int
|
||||
{
|
||||
if (str_starts_with($phone, '+45')) {
|
||||
return 45;
|
||||
}
|
||||
if (str_starts_with($phone, '+46')) {
|
||||
return 46;
|
||||
}
|
||||
if (str_starts_with($phone, '+47')) {
|
||||
return 47;
|
||||
}
|
||||
if (str_starts_with($phone, '+358')) {
|
||||
return 358;
|
||||
}
|
||||
if (str_starts_with($phone, '+49')) {
|
||||
return 49;
|
||||
}
|
||||
if (str_starts_with($phone, '+44')) {
|
||||
return 44;
|
||||
}
|
||||
if (str_starts_with($phone, '+1')) {
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function matchesPhoneCallGateConfig(array $config): bool
|
||||
{
|
||||
return strtoupper(trim((string)($config['type'] ?? ''))) === 'PHONE_CALL';
|
||||
}
|
||||
|
||||
protected function resolveBirdClient(): bird
|
||||
{
|
||||
return new bird();
|
||||
}
|
||||
|
||||
protected function resolveSlackClient(): slack
|
||||
{
|
||||
return new slack();
|
||||
}
|
||||
|
||||
protected function resolveEdgeGatewayManager(): edge_gateway_manager
|
||||
{
|
||||
return new edge_gateway_manager();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function getPhoneCallDepartmentSummaries(): array
|
||||
{
|
||||
$summaries = [];
|
||||
$gateRows = self::getFieldsWhere(
|
||||
[
|
||||
'deleted_at' => null,
|
||||
],
|
||||
[
|
||||
'id',
|
||||
],
|
||||
);
|
||||
|
||||
foreach ($gateRows as $gateRow) {
|
||||
$gate = (new department_gates_o())->select((int)$gateRow['id']);
|
||||
if (!$gate->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = (array)$gate->config->value();
|
||||
if (!$this->matchesPhoneCallGateConfig($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$departmentId = (int)$gate->department->value();
|
||||
if ($departmentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($summaries[$departmentId])) {
|
||||
$departmentRow = (new departments_o())->getDepartmentById($departmentId);
|
||||
$summaries[$departmentId] = [
|
||||
'department_id' => $departmentId,
|
||||
'department_name' => trim((string)($departmentRow['name'] ?? ('Afdeling ' . $departmentId))),
|
||||
'order_priority' => (int)($departmentRow['order_priority'] ?? PHP_INT_MAX),
|
||||
'has_entrance_gate' => false,
|
||||
'has_exit_gate' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ((bool)$gate->is_entrance->value()) {
|
||||
$summaries[$departmentId]['has_entrance_gate'] = true;
|
||||
}
|
||||
if ((bool)$gate->is_exit->value()) {
|
||||
$summaries[$departmentId]['has_exit_gate'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$summaries = array_values(array_filter($summaries, static function (array $summary): bool {
|
||||
return ($summary['has_entrance_gate'] ?? false) === true
|
||||
|| ($summary['has_exit_gate'] ?? false) === true;
|
||||
}));
|
||||
|
||||
usort($summaries, static function (array $left, array $right): int {
|
||||
$leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX);
|
||||
$rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX);
|
||||
if ($leftPriority !== $rightPriority) {
|
||||
return $leftPriority <=> $rightPriority;
|
||||
}
|
||||
|
||||
return (int)($left['department_id'] ?? 0) <=> (int)($right['department_id'] ?? 0);
|
||||
});
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
public function getEntrancePhoneCallGate(int $department_id): ?department_gates_o
|
||||
{
|
||||
$gates = $this->getDepartmentGates($department_id);
|
||||
foreach ($gates as $gate) {
|
||||
if (!$gate->exists() || !(bool)$gate->is_entrance->value()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->matchesPhoneCallGateConfig((array)$gate->config->value())) {
|
||||
return $gate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getExitPhoneCallGate(int $department_id): ?department_gates_o
|
||||
{
|
||||
$gates = $this->getDepartmentGates($department_id);
|
||||
foreach ($gates as $gate) {
|
||||
if (!$gate->exists() || !(bool)$gate->is_exit->value()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->matchesPhoneCallGateConfig((array)$gate->config->value())) {
|
||||
return $gate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function openGate(): void
|
||||
{
|
||||
$this->requireSelected();
|
||||
$config = (array)$this->config->value();
|
||||
|
||||
if ($this->matchesPhoneCallGateConfig($config)) {
|
||||
$this->openPhoneCallGate($config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strtoupper(trim((string)($config['type'] ?? ''))) === 'RELAY') {
|
||||
$this->openRelayBackedGate($config);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function openPhoneCallGate(array $config): void
|
||||
{
|
||||
if (!isset($config['phone_number'])) {
|
||||
throw new Exception('Phone number is required for PHONE_CALL gate type');
|
||||
}
|
||||
|
||||
$normalized = self::normalizePhoneCandidate($config['phone_number']);
|
||||
if ($normalized === null) {
|
||||
throw new Exception('Invalid phone number for PHONE_CALL gate type');
|
||||
}
|
||||
[$countryCode, $phone] = $normalized;
|
||||
$ringTimeout = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : 30;
|
||||
$client = $this->resolveBirdClient();
|
||||
try {
|
||||
$client->callGatePreferringFlashCall($countryCode, $phone, $ringTimeout);
|
||||
} catch (\Throwable $e) {
|
||||
$this->resolveSlackClient()->send_message(
|
||||
'Failed to call gate for phone ' . $countryCode . ' ' . $phone . ': ' . $e->getMessage(),
|
||||
'Bird Voice Call Webhooks'
|
||||
);
|
||||
throw new Exception('Failed to open gate relay via phone call', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function openRelayBackedGate(array $config): void
|
||||
{
|
||||
$relayId = trim((string)($config['relay_id'] ?? ''));
|
||||
if ($relayId === '') {
|
||||
throw new Exception('relay_id is required for RELAY gate type');
|
||||
}
|
||||
|
||||
$departmentId = (int)$this->department->value();
|
||||
if ($departmentId <= 0) {
|
||||
throw new Exception('Gate department is invalid');
|
||||
}
|
||||
|
||||
$pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1;
|
||||
$manager = $this->resolveEdgeGatewayManager();
|
||||
$manager->dispatchRelaySwitch($departmentId, $relayId, true, [
|
||||
'module' => 'department_gates',
|
||||
'reason' => 'Open relay-backed department gate',
|
||||
'gate_id' => (int)$this->id,
|
||||
'gate_name' => (string)$this->name->value(),
|
||||
'relay_id' => $relayId,
|
||||
'pulse_seconds' => $pulseSeconds,
|
||||
]);
|
||||
|
||||
if ($pulseSeconds > 0) {
|
||||
usleep($pulseSeconds * 1000000);
|
||||
$manager->dispatchRelaySwitch($departmentId, $relayId, false, [
|
||||
'module' => 'department_gates',
|
||||
'reason' => 'Close relay-backed department gate after pulse',
|
||||
'gate_id' => (int)$this->id,
|
||||
'gate_name' => (string)$this->name->value(),
|
||||
'relay_id' => $relayId,
|
||||
'pulse_seconds' => $pulseSeconds,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace objects;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
@@ -17,7 +18,11 @@ class department_lanes_o extends db
|
||||
public object_property $relay_in_id; // The Shelly relay for the entrance port (if applicable)
|
||||
public object_property $relay_out_id; // The Shelly relay for the exit port (if applicable)
|
||||
public object_property $relay_machine_id; // The Shelly relay for the machine (if applicable)
|
||||
public object_property $relay_machine_program_picker_id; // The Shelly relay for the machine program picker (if applicable)
|
||||
public object_property $relay_machine_cleaner_id; // The Shelly relay for the machine cleaner (if applicable)
|
||||
public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable)
|
||||
public object_property $machine_type_id; // The reusable self-serve machine type for the lane (if applicable)
|
||||
public object_property $selfserve_enabled; // Whether this lane can be used for self-serve when department self-serve is enabled
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
@@ -25,6 +30,7 @@ class department_lanes_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('department_lanes');
|
||||
}
|
||||
|
||||
@@ -47,7 +53,7 @@ class department_lanes_o extends db
|
||||
* @return department_lanes_o
|
||||
* @throws Exception If the object was not created successfully
|
||||
*/
|
||||
public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, int $dynamic_image_id = null): department_lanes_o
|
||||
public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null, bool $selfserve_enabled = true): department_lanes_o
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
@@ -63,12 +69,24 @@ class department_lanes_o extends db
|
||||
if (!is_null($relay_machine_id)) {
|
||||
$relay_machine_id = $db->escape_string($relay_machine_id);
|
||||
}
|
||||
if (!is_null($relay_machine_program_picker_id)) {
|
||||
$relay_machine_program_picker_id = $db->escape_string($relay_machine_program_picker_id);
|
||||
}
|
||||
if (!is_null($relay_machine_cleaner_id)) {
|
||||
$relay_machine_cleaner_id = $db->escape_string($relay_machine_cleaner_id);
|
||||
}
|
||||
if (!is_null($dynamic_image_id)) {
|
||||
$dynamic_image_id = (int)$dynamic_image_id;
|
||||
if ($dynamic_image_id <= 0) {
|
||||
throw new Exception('dynamic_image_id must be a positive integer');
|
||||
}
|
||||
}
|
||||
if (!is_null($machine_type_id)) {
|
||||
$machine_type_id = (int)$machine_type_id;
|
||||
if ($machine_type_id <= 0) {
|
||||
throw new Exception('machine_type_id must be a positive integer');
|
||||
}
|
||||
}
|
||||
// Add the object
|
||||
$tmp_id = self::add_object([
|
||||
'department' => $department,
|
||||
@@ -76,7 +94,11 @@ class department_lanes_o extends db
|
||||
...(!is_null($relay_in_id) ? ['relay_in_id' => $relay_in_id] : []), // If the relay_in_id is null, it will be set to null in the database
|
||||
...(!is_null($relay_out_id) ? ['relay_out_id' => $relay_out_id] : []), // If the relay_out_id is null, it will be set to null in the database
|
||||
...(!is_null($relay_machine_id) ? ['relay_machine_id' => $relay_machine_id] : []), // If the relay_machine_id is null, it will be set to null in the database
|
||||
...(!is_null($relay_machine_program_picker_id) ? ['relay_machine_program_picker_id' => $relay_machine_program_picker_id] : []),
|
||||
...(!is_null($relay_machine_cleaner_id) ? ['relay_machine_cleaner_id' => $relay_machine_cleaner_id] : []),
|
||||
...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database
|
||||
...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []),
|
||||
'selfserve_enabled' => $selfserve_enabled ? 1 : 0,
|
||||
]);
|
||||
$this->id = $tmp_id;
|
||||
self::getObjectProperties();
|
||||
@@ -91,7 +113,11 @@ class department_lanes_o extends db
|
||||
$this->relay_in_id = new object_property($this->table, $this->id, 'relay_in_id', 'string', false);
|
||||
$this->relay_out_id = new object_property($this->table, $this->id, 'relay_out_id', 'string', false);
|
||||
$this->relay_machine_id = new object_property($this->table, $this->id, 'relay_machine_id', 'string', false);
|
||||
$this->relay_machine_program_picker_id = new object_property($this->table, $this->id, 'relay_machine_program_picker_id', 'string', false);
|
||||
$this->relay_machine_cleaner_id = new object_property($this->table, $this->id, 'relay_machine_cleaner_id', 'string', false);
|
||||
$this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false);
|
||||
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
|
||||
$this->selfserve_enabled = new object_property($this->table, $this->id, 'selfserve_enabled', 'bool', false, true);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
@@ -104,6 +130,10 @@ class department_lanes_o extends db
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$status = (string)$this->getLaneStatus()->name;
|
||||
$machine_status_audit = $this->getMachineStatusAudit();
|
||||
$selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department' => (int)$this->department->value(),
|
||||
@@ -111,15 +141,181 @@ class department_lanes_o extends db
|
||||
'relay_in_id' => (string)$this->relay_in_id->value(),
|
||||
'relay_out_id' => (string)$this->relay_out_id->value(),
|
||||
'relay_machine_id' => (string)$this->relay_machine_id->value(),
|
||||
'relay_machine_program_picker_id' => (string)$this->relay_machine_program_picker_id->value(),
|
||||
'relay_machine_cleaner_id' => (string)$this->relay_machine_cleaner_id->value(),
|
||||
'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()),
|
||||
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
|
||||
'selfserve_enabled' => $this->isSelfServeEnabled(),
|
||||
// Status of the lane
|
||||
'status' => (string)$this->getLaneStatus()->name,
|
||||
'status' => $status,
|
||||
'machine_status_enabled' => self::isOperationalStatusName($status),
|
||||
'machine_status_audit' => $machine_status_audit,
|
||||
'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null,
|
||||
'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null,
|
||||
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null,
|
||||
'selfserve_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configuration_warnings' => $selfserve_configuration_warnings,
|
||||
// Timestamps
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getMachineStatusAudit(): ?array
|
||||
{
|
||||
try {
|
||||
$lane = (new selfserve())->lane((int)$this->id);
|
||||
if (!method_exists($lane, 'getLaneStatusAudit')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$audit = $lane->getLaneStatusAudit();
|
||||
return is_array($audit) ? $audit : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getSelfServeConfigurationWarnings(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$required_fields = [
|
||||
'relay_in_id' => 'Indgangsrelæ',
|
||||
'relay_out_id' => 'Udgangsrelæ',
|
||||
'relay_machine_id' => 'Maskinrelæ',
|
||||
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
|
||||
'relay_machine_cleaner_id' => 'Vaskerelæ',
|
||||
'dynamic_image_id' => 'Maskinstatusbillede',
|
||||
'machine_type_id' => 'Maskintype',
|
||||
];
|
||||
|
||||
$warnings = [];
|
||||
foreach ($required_fields as $field => $label) {
|
||||
if ($this->hasConfiguredFieldValue($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warnings[] = [
|
||||
'field' => $field,
|
||||
'label' => $label,
|
||||
'message' => $label . ' mangler',
|
||||
];
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
public function isSelfServeConfigured(): bool
|
||||
{
|
||||
return $this->getSelfServeConfigurationWarnings() === [];
|
||||
}
|
||||
|
||||
public static function isOperationalStatusName(string $status): bool
|
||||
{
|
||||
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
|
||||
}
|
||||
|
||||
public function isSelfServeEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
try {
|
||||
$value = $this->selfserve_enabled->value();
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public static function normalizeSelfServeEnabledValue(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function hasConfiguredFieldValue(string $field): bool
|
||||
{
|
||||
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $this->{$field}->value();
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value > 0;
|
||||
}
|
||||
|
||||
return (bool)$value;
|
||||
}
|
||||
|
||||
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
|
||||
{
|
||||
if ($lane_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$lane = (new selfserve())->lane($lane_id);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::setLaneRelayOffIfConfigured($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
|
||||
$lane->setMachineProgramPickerRelayStatusHard(false);
|
||||
});
|
||||
self::setLaneRelayOffIfConfigured($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
|
||||
$lane->setMachineCleanerRelayStatusHard(false);
|
||||
});
|
||||
self::setLaneRelayOffIfConfigured($lane, 'relay_machine_id', static function () use ($lane): void {
|
||||
$lane->setMachineRelayStatusHard(false);
|
||||
});
|
||||
}
|
||||
|
||||
private static function setLaneRelayOffIfConfigured(object $lane, string $relay_property, callable $callback): void
|
||||
{
|
||||
if (
|
||||
empty($lane->department_lane)
|
||||
|| !isset($lane->department_lane->{$relay_property})
|
||||
|| !is_object($lane->department_lane->{$relay_property})
|
||||
|| !method_exists($lane->department_lane->{$relay_property}, 'value')
|
||||
|| trim((string)$lane->department_lane->{$relay_property}->value()) === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; toggling lane self-serve should not fail on relay I/O.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the self-serve lane products available for this lane
|
||||
* @return array An array of product ids available for this lane
|
||||
@@ -152,4 +348,4 @@ class department_lanes_o extends db
|
||||
}
|
||||
return $lanes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
@@ -11,10 +12,19 @@ class department_selfserve_conditions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
/**
|
||||
* Canonical relationship model:
|
||||
* - A condition is a reusable logical node.
|
||||
* - `condition_id` on this entity is a parent condition id, enabling nested condition trees.
|
||||
* - Questions may reference a condition as their gate.
|
||||
* - Tasks may reference a question as their gate (legacy stored in tasks.condition_id).
|
||||
*/
|
||||
|
||||
public object_property $department; // The department id
|
||||
public object_property $lane; // The lane id
|
||||
public object_property $product; // The product id
|
||||
public object_property $condition_id; // The condition id (optional)
|
||||
public object_property $machine_type_id; // The reusable machine type id (nullable, preferred over department/lane/product)
|
||||
public object_property $condition_id; // Parent condition id for nesting/grouping (nullable)
|
||||
public object_property $name; // The condition name
|
||||
public object_property $description; // The task description
|
||||
public object_property $created_at;
|
||||
@@ -24,6 +34,7 @@ class department_selfserve_conditions_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('department_selfserve_conditions');
|
||||
}
|
||||
|
||||
@@ -34,11 +45,11 @@ class department_selfserve_conditions_o extends db
|
||||
* @param int $product The product id
|
||||
* @param string $name The condition name
|
||||
* @param string $description The condition description
|
||||
* @param int|null $condition_id The condition id (optional)
|
||||
* @param int|null $condition_id Optional parent condition id for nesting/grouping.
|
||||
* @return department_selfserve_conditions_o
|
||||
* @throws Exception If the object was not created successfully
|
||||
*/
|
||||
public function add(int $department, int $lane, int $product, string $name, string $description, int $condition_id = null): department_selfserve_conditions_o
|
||||
public function add(int $department, int $lane, int $product, string $name, string $description, int $condition_id = null, ?int $machine_type_id = null): department_selfserve_conditions_o
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
@@ -51,12 +62,16 @@ class department_selfserve_conditions_o extends db
|
||||
if (!is_null($condition_id)) {
|
||||
$condition_id = (int)$condition_id;
|
||||
}
|
||||
if (!is_null($machine_type_id)) {
|
||||
$machine_type_id = (int)$machine_type_id;
|
||||
}
|
||||
|
||||
// Add the object
|
||||
$tmp_id = self::add_object([
|
||||
'department' => $department,
|
||||
'lane' => $lane,
|
||||
'product' => $product,
|
||||
...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []),
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
...(!is_null($condition_id) ? ['condition_id' => $condition_id] : []),
|
||||
@@ -74,6 +89,7 @@ class department_selfserve_conditions_o extends db
|
||||
$this->department = new object_property($this->table, $this->id, 'department', 'int', false);
|
||||
$this->lane = new object_property($this->table, $this->id, 'lane', 'int', false);
|
||||
$this->product = new object_property($this->table, $this->id, 'product', 'int', false);
|
||||
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
|
||||
$this->condition_id = new object_property($this->table, $this->id, 'condition_id', 'int', false);
|
||||
$this->name = new object_property($this->table, $this->id, 'name', 'string', false);
|
||||
$this->description = new object_property($this->table, $this->id, 'description', 'string', false);
|
||||
@@ -94,6 +110,7 @@ class department_selfserve_conditions_o extends db
|
||||
'department' => (int)$this->department->value(),
|
||||
'lane' => (int)$this->lane->value(),
|
||||
'product' => (int)$this->product->value(),
|
||||
'machine_type_id' => is_null($this->machine_type_id->value()) ? null : (int)$this->machine_type_id->value(),
|
||||
'condition_id' => is_null($this->condition_id->value()) ? null : (int)$this->condition_id->value(),
|
||||
'name' => (string)$this->name->value(),
|
||||
'description' => (string)$this->description->value(),
|
||||
@@ -102,4 +119,22 @@ class department_selfserve_conditions_o extends db
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function getConditionsForMachineType(int $machineTypeId): array
|
||||
{
|
||||
return self::getFieldsWhere([
|
||||
'machine_type_id' => $machineTypeId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']);
|
||||
}
|
||||
|
||||
public function getLegacyConditionsForLaneProduct(int $departmentId, int $laneId, int $productId): array
|
||||
{
|
||||
return self::getFieldsWhere([
|
||||
'department' => $departmentId,
|
||||
'lane' => $laneId,
|
||||
'product' => $productId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,19 @@ use traits\db_object_t;
|
||||
|
||||
class department_selfserve_questions_o extends db
|
||||
{
|
||||
use db_object_t {
|
||||
delete as trait_delete;
|
||||
}
|
||||
use db_object_t;
|
||||
|
||||
public object_property $department; // The department id
|
||||
public object_property $lane; // The lane id
|
||||
public object_property $product; // The product id
|
||||
public object_property $condition_id; // The question condition object id (if applicable)
|
||||
/**
|
||||
* Canonical relationship model:
|
||||
* - Question `condition_id` references `department_selfserve_conditions.id` and means
|
||||
* "show/ask this question only when this parent condition is met".
|
||||
* - Tasks are linked to questions (not conditions); tasks use adapter methods in tasks object
|
||||
* while persisting to legacy `department_selfserve_tasks.condition_id`.
|
||||
*/
|
||||
public object_property $condition_id; // Parent condition id that gates this question (nullable)
|
||||
public object_property $question; // The question text
|
||||
public object_property $description; // The question description
|
||||
public object_property $order_priority; // The order priority of the question (lower numbers are shown first)
|
||||
@@ -37,7 +42,7 @@ class department_selfserve_questions_o extends db
|
||||
* @param int $product The product id
|
||||
* @param string $question The question text
|
||||
* @param string $description The question description
|
||||
* @param int|null $condition_id The question condition_id (if applicable)
|
||||
* @param int|null $condition_id Optional parent condition id that gates this question.
|
||||
* @param int $order_priority The order priority of the question (lower numbers are shown first)
|
||||
* @return department_selfserve_questions_o
|
||||
* @throws Exception If the object was not created successfully
|
||||
@@ -96,9 +101,17 @@ class department_selfserve_questions_o extends db
|
||||
self::requireSelected();
|
||||
global $db;
|
||||
$id = (int)$this->id;
|
||||
// Deleting a question detaches dependent tasks by clearing the legacy
|
||||
// `department_selfserve_tasks.condition_id` link (which semantically stores question id).
|
||||
$sql = "UPDATE department_selfserve_tasks SET condition_id = NULL WHERE condition_id = $id";
|
||||
$db->query($sql);
|
||||
$this->trait_delete();
|
||||
|
||||
if (self::columnsExist(['deleted_at'])) {
|
||||
self::update(['deleted_at' => date('Y-m-d H:i:s')]);
|
||||
return;
|
||||
}
|
||||
|
||||
self::deletePermanently();
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
@@ -117,4 +130,24 @@ class department_selfserve_questions_o extends db
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function getSharedQuestions(): array
|
||||
{
|
||||
return self::getFieldsWhere([
|
||||
'department' => 0,
|
||||
'lane' => 0,
|
||||
'product' => 0,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']);
|
||||
}
|
||||
|
||||
public function getLegacyQuestionsForLaneProduct(int $departmentId, int $laneId, int $productId): array
|
||||
{
|
||||
return self::getFieldsWhere([
|
||||
'department' => $departmentId,
|
||||
'lane' => $laneId,
|
||||
'product' => $productId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,36 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use Exception;
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
use modules\selfserve\helpers\selfserve_lane_services;
|
||||
use modules\selfserve\helpers\selfserve_task_gate_type;
|
||||
use traits\db_object_t;
|
||||
|
||||
class department_selfserve_tasks_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
/**
|
||||
* Canonical relationship model for self-serve entities:
|
||||
* - Conditions can be nested via `department_selfserve_conditions.condition_id` (parent condition id).
|
||||
* - Questions can optionally be gated by a condition via `department_selfserve_questions.condition_id` (parent condition id).
|
||||
* - Tasks can optionally be gated by a question. For backward compatibility this is stored in
|
||||
* `department_selfserve_tasks.condition_id`, but semantically this is a question reference.
|
||||
*/
|
||||
|
||||
public object_property $department; // The department id
|
||||
public object_property $lane; // The lane id
|
||||
public object_property $product; // The product id
|
||||
public object_property $condition_id; // The question id (if conditional task)
|
||||
public object_property $machine_type_id; // The reusable machine type id (nullable, preferred over department/lane/product)
|
||||
public object_property $condition_id; // Legacy column name: stores question id that gates this task (nullable)
|
||||
public object_property $gate_type; // Canonical gate type: ALWAYS|CONDITION|QUESTION
|
||||
public object_property $gate_ref_id; // Canonical gate reference id (nullable)
|
||||
public object_property $task; // The task
|
||||
public object_property $description; // The task description
|
||||
public object_property $order_priority; // The order priority of the task (lower numbers are shown first)
|
||||
public object_property $services; // The services that the task enables (json), this is used to enable machine wash.
|
||||
public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of button ids)
|
||||
public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of mapped button ids)
|
||||
public object_property $dynamic_images_vehicle_type; // The vehicle type selection override on the machine, used by dynamicimages - int or null if not applicable.
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
@@ -46,6 +58,7 @@ class department_selfserve_tasks_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('department_selfserve_tasks');
|
||||
}
|
||||
|
||||
@@ -54,17 +67,31 @@ class department_selfserve_tasks_o extends db
|
||||
* @param int $department The department id
|
||||
* @param int $lane The lane id
|
||||
* @param int $product The product id
|
||||
* @param int|null $condition_id The question id (if conditional task)
|
||||
* @param int|null $question_id Optional question id that gates this task. Persisted in legacy `condition_id` column.
|
||||
* @param string $task The task text
|
||||
* @param string $description The task description
|
||||
* @param int $order_priority The order priority of the task (lower numbers are shown first)
|
||||
* @param selfserve_lane_services[]|string[]|null $services The services that the task enables (stored as JSON array of service names). May be an array of enum cases or names.
|
||||
* @param array<int>|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts array of ints or a parsable string/JSON.
|
||||
* @param array<int|string>|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts program integers plus "reset" and "start".
|
||||
* @param int|null $dynamic_images_vehicle_type Optional vehicle type selection override for the machine UI. Integer >= 0 or null.
|
||||
* @return department_selfserve_tasks_o
|
||||
* @throws Exception If the object was not created successfully
|
||||
*/
|
||||
public function add(int $department, int $lane, int $product, int|null $condition_id, string $task, string $description, int $order_priority = 0, ?array $services = null, array|string|null $buttons = null, int|null $dynamic_images_vehicle_type = null): self
|
||||
public function add(
|
||||
int $department,
|
||||
int $lane,
|
||||
int $product,
|
||||
int|null $question_id,
|
||||
string $task,
|
||||
string $description,
|
||||
int $order_priority = 0,
|
||||
?array $services = null,
|
||||
array|string|null $buttons = null,
|
||||
int|null $dynamic_images_vehicle_type = null,
|
||||
?int $machine_type_id = null,
|
||||
?selfserve_task_gate_type $gate_type = null,
|
||||
?int $gate_ref_id = null,
|
||||
): self
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
@@ -72,8 +99,14 @@ class department_selfserve_tasks_o extends db
|
||||
$department = (int)$department;
|
||||
$lane = (int)$lane;
|
||||
$product = (int)$product;
|
||||
if (!is_null($condition_id)) {
|
||||
$condition_id = (int)$condition_id;
|
||||
if (!is_null($question_id)) {
|
||||
$question_id = (int)$question_id;
|
||||
}
|
||||
if (!is_null($machine_type_id)) {
|
||||
$machine_type_id = (int)$machine_type_id;
|
||||
}
|
||||
if (!is_null($gate_ref_id)) {
|
||||
$gate_ref_id = (int)$gate_ref_id;
|
||||
}
|
||||
$task = $db->escape_string($task);
|
||||
$description = $db->escape_string($description);
|
||||
@@ -119,12 +152,25 @@ class department_selfserve_tasks_o extends db
|
||||
}
|
||||
}
|
||||
|
||||
if ($gate_type === null) {
|
||||
$resolvedGate = self::resolveLegacyGateDefinition($question_id);
|
||||
$gate_type = $resolvedGate['gate_type'];
|
||||
$gate_ref_id = $resolvedGate['gate_ref_id'];
|
||||
} elseif ($gate_type === selfserve_task_gate_type::ALWAYS) {
|
||||
$gate_ref_id = null;
|
||||
} elseif ($gate_ref_id === null || $gate_ref_id <= 0) {
|
||||
throw new Exception('gate_ref_id must be provided for CONDITION and QUESTION task gate types');
|
||||
}
|
||||
|
||||
// Add the object
|
||||
$tmp_id = self::add_object([
|
||||
'department' => $department,
|
||||
'lane' => $lane,
|
||||
'product' => $product,
|
||||
...(!is_null($condition_id) ? ['condition_id' => $condition_id] : []), // If the question is null, it will be set to null in the database
|
||||
...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []),
|
||||
...(!is_null($question_id) ? ['condition_id' => $question_id] : []), // Legacy column name; contains the gating question id
|
||||
'gate_type' => $gate_type->value,
|
||||
'gate_ref_id' => $gate_ref_id,
|
||||
'task' => $task,
|
||||
'description' => $description,
|
||||
'order_priority' => $order_priority,
|
||||
@@ -144,7 +190,10 @@ class department_selfserve_tasks_o extends db
|
||||
$this->department = new object_property($this->table, $this->id, 'department', 'int', false);
|
||||
$this->lane = new object_property($this->table, $this->id, 'lane', 'int', false);
|
||||
$this->product = new object_property($this->table, $this->id, 'product', 'int', false);
|
||||
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
|
||||
$this->condition_id = new object_property($this->table, $this->id, 'condition_id', 'int', true);
|
||||
$this->gate_type = new object_property($this->table, $this->id, 'gate_type', 'string', false);
|
||||
$this->gate_ref_id = new object_property($this->table, $this->id, 'gate_ref_id', 'int', true);
|
||||
$this->task = new object_property($this->table, $this->id, 'task', 'string', false);
|
||||
$this->description = new object_property($this->table, $this->id, 'description', 'string', false);
|
||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||
@@ -168,7 +217,10 @@ class department_selfserve_tasks_o extends db
|
||||
'department' => (int)$this->department->value(),
|
||||
'lane' => (int)$this->lane->value(),
|
||||
'product' => (int)$this->product->value(),
|
||||
'machine_type_id' => is_null($this->machine_type_id->value()) ? null : (int)$this->machine_type_id->value(),
|
||||
'condition_id' => is_null($this->condition_id->value()) ? null : (int)$this->condition_id->value(),
|
||||
'gate_type' => (string)($this->gate_type->value() ?? selfserve_task_gate_type::ALWAYS->value),
|
||||
'gate_ref_id' => is_null($this->gate_ref_id->value()) ? null : (int)$this->gate_ref_id->value(),
|
||||
'task' => (string)$this->task->value(),
|
||||
'description' => (string)$this->description->value(),
|
||||
'order_priority' => (int)$this->order_priority->value(),
|
||||
@@ -181,6 +233,96 @@ class department_selfserve_tasks_o extends db
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for canonical naming.
|
||||
* @return int|null Question id that gates this task.
|
||||
*/
|
||||
public function getQuestionId(): ?int
|
||||
{
|
||||
$value = $this->condition_id->value();
|
||||
return is_null($value) ? null : (int)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for canonical naming while persisting to legacy `condition_id` column.
|
||||
* @param int|null $question_id
|
||||
* @return void
|
||||
*/
|
||||
public function setQuestionId(?int $question_id): void
|
||||
{
|
||||
$this->condition_id->set(is_null($question_id) ? null : (int)$question_id);
|
||||
$resolvedGate = self::resolveLegacyGateDefinition($question_id);
|
||||
$this->gate_type->set($resolvedGate['gate_type']->value);
|
||||
$this->gate_ref_id->set($resolvedGate['gate_ref_id']);
|
||||
}
|
||||
|
||||
public function getTasksForMachineType(int $machineTypeId): array
|
||||
{
|
||||
return self::getFieldsWhere([
|
||||
'machine_type_id' => $machineTypeId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']);
|
||||
}
|
||||
|
||||
public function getLegacyTasksForLaneProduct(int $departmentId, int $laneId, int $productId): array
|
||||
{
|
||||
return self::getFieldsWhere([
|
||||
'department' => $departmentId,
|
||||
'lane' => $laneId,
|
||||
'product' => $productId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize external gate_type inputs.
|
||||
* @param mixed $input
|
||||
* @return selfserve_task_gate_type
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function normalizeGateTypeInput(mixed $input): selfserve_task_gate_type
|
||||
{
|
||||
if ($input instanceof selfserve_task_gate_type) {
|
||||
return $input;
|
||||
}
|
||||
if (is_string($input)) {
|
||||
$normalized = strtoupper(trim($input));
|
||||
$gateType = selfserve_task_gate_type::tryFrom($normalized);
|
||||
if ($gateType !== null) {
|
||||
return $gateType;
|
||||
}
|
||||
}
|
||||
throw new Exception('Invalid gate_type. Expected ALWAYS, CONDITION, or QUESTION.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a typed gate from legacy `condition_id` input.
|
||||
* @param int|null $legacyGateId
|
||||
* @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null}
|
||||
*/
|
||||
public static function resolveLegacyGateDefinition(?int $legacyGateId): array
|
||||
{
|
||||
if ($legacyGateId === null || $legacyGateId <= 0) {
|
||||
return [
|
||||
'gate_type' => selfserve_task_gate_type::ALWAYS,
|
||||
'gate_ref_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$condition = (new department_selfserve_conditions_o())->select((int)$legacyGateId);
|
||||
if ($condition->exists()) {
|
||||
return [
|
||||
'gate_type' => selfserve_task_gate_type::CONDITION,
|
||||
'gate_ref_id' => (int)$legacyGateId,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'gate_type' => selfserve_task_gate_type::QUESTION,
|
||||
'gate_ref_id' => (int)$legacyGateId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize input for dynamic_images_vehicle_type into a nullable non-negative integer.
|
||||
* Accepts int, string (numeric), null, or empty string (treated as null).
|
||||
@@ -216,13 +358,13 @@ class department_selfserve_tasks_o extends db
|
||||
return $val;
|
||||
}
|
||||
/**
|
||||
* Normalize mixed input for buttons into an array of integer IDs (>= 0).
|
||||
* Normalize mixed input for buttons into an array of mapped button IDs.
|
||||
* Accepts:
|
||||
* - array of ints/strings
|
||||
* - JSON array string
|
||||
* - comma-separated string
|
||||
* @param mixed $input
|
||||
* @return array<int>
|
||||
* @return array<int|string>
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function normalizeButtonsInput(mixed $input): array
|
||||
@@ -237,14 +379,22 @@ class department_selfserve_tasks_o extends db
|
||||
}
|
||||
}
|
||||
if (!is_array($raw)) {
|
||||
throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of integers.');
|
||||
throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of mapped button ids.');
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($raw as $btn) {
|
||||
if (is_string($btn)) {
|
||||
$trimmed = trim($btn);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
|
||||
$ids[] = $specialButton;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (is_int($btn)) {
|
||||
$val = $btn;
|
||||
} elseif (is_string($btn) && ctype_digit($btn)) {
|
||||
$val = (int)$btn;
|
||||
} elseif (is_string($btn) && ctype_digit(trim($btn))) {
|
||||
$val = (int)trim($btn);
|
||||
} elseif (is_numeric($btn) && (int)$btn == $btn) {
|
||||
$val = (int)$btn;
|
||||
} else {
|
||||
@@ -256,7 +406,16 @@ class department_selfserve_tasks_o extends db
|
||||
$ids[] = $val;
|
||||
}
|
||||
// de-duplicate while preserving order
|
||||
$ids = array_values(array_unique($ids));
|
||||
return $ids;
|
||||
$deduped = [];
|
||||
$seen = [];
|
||||
foreach ($ids as $id) {
|
||||
$key = (is_int($id) ? 'int:' : 'string:') . (string)$id;
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$deduped[] = $id;
|
||||
}
|
||||
return $deduped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,10 @@ class department_selfserve_vehicle_conditions_o extends db
|
||||
$question = (int)$question;
|
||||
$value = (bool)$value;
|
||||
$customer_id = $customer_id !== null ? (int)$customer_id : null;
|
||||
// Remove any existing entry for the same department, lane, reg and question
|
||||
$sql = "DELETE FROM $this->table WHERE department = $department AND lane = $lane AND reg = '$reg' AND question = $question";
|
||||
// Remove any existing entry for the same department, lane, customer, reg and question.
|
||||
// Saved answers must not bleed across customers that temporarily wash the same plate.
|
||||
$customer_filter = $customer_id === null ? 'customer_id IS NULL' : 'customer_id = ' . $customer_id;
|
||||
$sql = "DELETE FROM $this->table WHERE department = $department AND lane = $lane AND $customer_filter AND reg = '$reg' AND question = $question";
|
||||
$db->query($sql);
|
||||
// Add the object
|
||||
$tmp_id = self::add_object([
|
||||
@@ -104,4 +106,26 @@ class department_selfserve_vehicle_conditions_o extends db
|
||||
'deleted_at' => is_null($this->deleted_at->value()) ? null : (string)$this->deleted_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function getAnswerMapForVehicle(int $departmentId, int $laneId, string $reg, ?int $customerId = null): array
|
||||
{
|
||||
if ($customerId === null || $customerId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = self::getFieldsWhere([
|
||||
'department' => $departmentId,
|
||||
'lane' => $laneId,
|
||||
'customer_id' => $customerId,
|
||||
'reg' => selfserve::standardize_registration($reg),
|
||||
'deleted_at' => null,
|
||||
], ['question', 'value']);
|
||||
|
||||
$answers = [];
|
||||
foreach ($rows as $row) {
|
||||
$answers[(int)$row['question']] = (bool)$row['value'];
|
||||
}
|
||||
|
||||
return $answers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\departments_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use classes\slack;
|
||||
use classes\stripe;
|
||||
@@ -20,6 +21,7 @@ class departments_o extends db
|
||||
public department_variables_o $variables; // The department variables object
|
||||
public object_property $dimension; // The dimension of the department
|
||||
public object_property $visible; // The visibility of the department
|
||||
public object_property $archived; // Whether the department is archived
|
||||
public object_property $branding; // The branding of the department
|
||||
public object_property $longitude; // The longitude of the department (Can be null)
|
||||
public object_property $latitude; // The latitude of the department (Can be null)
|
||||
@@ -29,6 +31,7 @@ class departments_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
departments_schema_bootstrap::ensureTables();
|
||||
$this->setTable('departments');
|
||||
}
|
||||
|
||||
@@ -103,6 +106,7 @@ class departments_o extends db
|
||||
$this->dimension = new object_property($this->table, $this->id, 'dimension', 'int', false);
|
||||
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
||||
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
|
||||
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
|
||||
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
|
||||
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
|
||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||
@@ -156,6 +160,7 @@ class departments_o extends db
|
||||
'description' => $department['description'],
|
||||
'id' => $department['id'],
|
||||
'visible' => $department['visible'],
|
||||
'archived' => $department['archived'] ?? 0,
|
||||
];
|
||||
}, $departments);
|
||||
}
|
||||
@@ -446,22 +451,39 @@ class departments_o extends db
|
||||
* Send department period statistics to Slack
|
||||
* @param string $start_date (YYYY-MM-DD)
|
||||
* @param string $end_date (YYYY-MM-DD)
|
||||
* @return void
|
||||
* @param int[] $product_ids The product ids to include in the statistics (e.g. [25, [23, 24], 22, 27, 21, 26])
|
||||
* @param bool $return_as_array Whether to return the results as an array or not
|
||||
* @return string[]
|
||||
* @throws Exception If the department is not selected
|
||||
*/
|
||||
public function sendPeriodStatisticsToSlack(string $start_date, string $end_date): void
|
||||
public function sendPeriodStatisticsToSlack(string $start_date, string $end_date, array $product_ids = [
|
||||
25,
|
||||
[23, 24], // Used to merge two products into one percentage (Spot Free)
|
||||
22,
|
||||
27,
|
||||
21,
|
||||
26
|
||||
], bool $return_as_array = false): array
|
||||
{
|
||||
self::requireSelected();
|
||||
// Ensure only one message is sent a week using redis cache
|
||||
$cacheKey = "department_{$this->id}_weekly_statistics_sent_v2";
|
||||
// Get time until next monday at 00:00:00
|
||||
$nextMonday = strtotime('next monday');
|
||||
$cacheDuration = $nextMonday - time();
|
||||
$lastSent = redis->get($cacheKey);
|
||||
// If null or more than a week has passed since the last message, send a new message
|
||||
$sendNow = match (true) {
|
||||
$lastSent === null => true,
|
||||
default => (time() - $lastSent) >= $cacheDuration
|
||||
};
|
||||
if (!$sendNow) {
|
||||
return $return_as_array ? ["A weekly statistics message has already been sent for this department."] : ["A weekly statistics message has already been sent for this department."];
|
||||
}
|
||||
$this->cache($cacheKey, $cacheDuration);
|
||||
$this->setCachedExpiration($cacheKey, $cacheDuration);
|
||||
// Configuration
|
||||
$department_id = $this->id;
|
||||
$product_ids = [
|
||||
25,
|
||||
[23, 24], // Used to merge two products into one percentage (Spot Free)
|
||||
22,
|
||||
27,
|
||||
21,
|
||||
26
|
||||
];
|
||||
$date_end = date('Y-m-d 23:59:59', strtotime($end_date)); // End date at 23:59:59
|
||||
$date_start = date('Y-m-d 00:00:00', strtotime($start_date)); // Start date at 00:00:00
|
||||
/**
|
||||
@@ -487,6 +509,10 @@ class departments_o extends db
|
||||
$wash_count = (new orders_o())->countWashesInDateRange($date_start, $date_end, $department_id);
|
||||
$analytics = $this->analyzeAddonSalesData($product_ids, $date_start, $date_end, $department_id, $max_addons, $sold_addons, $percentages);
|
||||
$tmp .= "Washes: $wash_count\n" . implode('', $analytics);
|
||||
// If it should be returned as an array, return it as an array instead of sending it to Slack
|
||||
if ($return_as_array) {
|
||||
return explode("\n", trim($tmp));
|
||||
}
|
||||
|
||||
// Check if there's a custom webhook for the department
|
||||
$slack = new slack();
|
||||
@@ -497,6 +523,8 @@ class departments_o extends db
|
||||
// Set the custom webhook for the department
|
||||
$slack->send_webhook_message($tmp, $this->slack_webhook->value());
|
||||
}
|
||||
|
||||
return explode("\n", trim($tmp));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -686,7 +714,8 @@ class departments_o extends db
|
||||
* @param string $date_end (YYYY-MM-DD)
|
||||
* @param int[] $department_ids
|
||||
* @param int[] $product_ids
|
||||
* @return void
|
||||
* @param bool $return_as_array Whether to return the results as an array or not
|
||||
* @return null|array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids, array $product_ids = [
|
||||
@@ -696,7 +725,7 @@ class departments_o extends db
|
||||
27,
|
||||
21,
|
||||
26
|
||||
]): void
|
||||
], bool $return_as_array = false): null|array
|
||||
{
|
||||
if (empty($department_ids)) {
|
||||
throw new Exception('No department ids provided for the Slack internal statistic notification.');
|
||||
@@ -810,8 +839,25 @@ class departments_o extends db
|
||||
}
|
||||
$tmp .= "> - Total: " . number_format($total_percentage, 2) . "%\n";
|
||||
}
|
||||
$array_of_results = [
|
||||
"daily_management" => [],
|
||||
"departments" => []
|
||||
];
|
||||
// Add $tmp to the daily management message
|
||||
$array_of_results['daily_management'][] = $tmp;
|
||||
|
||||
// Send the message to the internal Slack webhook
|
||||
$slack = new slack();
|
||||
$slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value());
|
||||
if (!$return_as_array) {
|
||||
$slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value());
|
||||
}
|
||||
// Send a department-specific message for each internal department with the percentage of addons sold
|
||||
foreach ( $department_ids as $department_id ) {
|
||||
$department = (new departments_o())->select($department_id);
|
||||
$tmp_dept = $department->sendPeriodStatisticsToSlack($date_start, $date_end, $product_ids, $return_as_array);
|
||||
$array_of_results['departments'][$department_id] = $tmp_dept;
|
||||
}
|
||||
|
||||
return $array_of_results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,61 @@ class economic_module_orders extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure rows exist for the provided order IDs using one batched INSERT IGNORE.
|
||||
*
|
||||
* @param int[] $orderIds
|
||||
*/
|
||||
public function ensureRowsForOrderIds(array $orderIds): void
|
||||
{
|
||||
$orderIds = $this->normalizeOrderIds($orderIds);
|
||||
if (empty($orderIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->performEnsureRowsInsert($orderIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get economic module payload for many orders as an id-keyed map.
|
||||
*
|
||||
* @param int[] $orderIds
|
||||
* @return array<int, array{id:int, invoice_draft_id:int|null, invoice_id:int|null}>
|
||||
*/
|
||||
public function getByOrderIdsAsArray(array $orderIds): array
|
||||
{
|
||||
$orderIds = $this->normalizeOrderIds($orderIds);
|
||||
if (empty($orderIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->fetchRowsByOrderIds($orderIds);
|
||||
$byId = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int)($row['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$byId[$id] = [
|
||||
'id' => $id,
|
||||
'invoice_draft_id' => isset($row['invoice_draft_id']) && $row['invoice_draft_id'] !== null ? (int)$row['invoice_draft_id'] : null,
|
||||
'invoice_id' => isset($row['invoice_id']) && $row['invoice_id'] !== null ? (int)$row['invoice_id'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($orderIds as $orderId) {
|
||||
if (!isset($byId[$orderId])) {
|
||||
$byId[$orderId] = [
|
||||
'id' => $orderId,
|
||||
'invoice_draft_id' => null,
|
||||
'invoice_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $byId;
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->economic_invoice_draft_id = new object_property($this->table, $this->id, 'invoice_draft_id', 'int', true);
|
||||
@@ -91,4 +146,38 @@ class economic_module_orders extends db
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $orderIds
|
||||
* @return int[]
|
||||
*/
|
||||
protected function normalizeOrderIds(array $orderIds): array
|
||||
{
|
||||
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0)));
|
||||
sort($orderIds);
|
||||
return $orderIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $orderIds
|
||||
*/
|
||||
protected function performEnsureRowsInsert(array $orderIds): void
|
||||
{
|
||||
global $db;
|
||||
$values = implode(',', array_map(static fn(int $id): string => "($id)", $orderIds));
|
||||
$sql = "INSERT IGNORE INTO $this->table (id) VALUES $values";
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $orderIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function fetchRowsByOrderIds(array $orderIds): array
|
||||
{
|
||||
return $this->getFieldsWhereIn(
|
||||
['id' => $orderIds],
|
||||
['id', 'invoice_draft_id', 'invoice_id']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_audit_logs_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $action;
|
||||
public object_property $actor_user_id;
|
||||
public object_property $actor_type;
|
||||
public object_property $severity;
|
||||
public object_property $context_json;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_audit_logs');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->action = new object_property($this->table, $this->id, 'action', 'string', false);
|
||||
$this->actor_user_id = new object_property($this->table, $this->id, 'actor_user_id', 'int', false);
|
||||
$this->actor_type = new object_property($this->table, $this->id, 'actor_type', 'string', false);
|
||||
$this->severity = new object_property($this->table, $this->id, 'severity', 'string', false);
|
||||
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => $this->gateway_id->value() === null ? null : (int)$this->gateway_id->value(),
|
||||
'department_id' => $this->department_id->value() === null ? null : (int)$this->department_id->value(),
|
||||
'action' => (string)$this->action->value(),
|
||||
'actor_user_id' => $this->actor_user_id->value() === null ? null : (int)$this->actor_user_id->value(),
|
||||
'actor_type' => (string)$this->actor_type->value(),
|
||||
'severity' => (string)$this->severity->value(),
|
||||
'context' => (array)($this->context_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_claim_tokens_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $department_id;
|
||||
public object_property $label;
|
||||
public object_property $token_hash;
|
||||
public object_property $created_by;
|
||||
public object_property $expires_at;
|
||||
public object_property $used_at;
|
||||
public object_property $metadata_json;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_claim_tokens');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->label = new object_property($this->table, $this->id, 'label', 'string', false);
|
||||
$this->token_hash = new object_property($this->table, $this->id, 'token_hash', 'string', false);
|
||||
$this->created_by = new object_property($this->table, $this->id, 'created_by', 'int', false);
|
||||
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
|
||||
$this->used_at = new object_property($this->table, $this->id, 'used_at', 'string', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'label' => $this->label->value() === null ? null : (string)$this->label->value(),
|
||||
'created_by' => $this->created_by->value() === null ? null : (int)$this->created_by->value(),
|
||||
'expires_at' => (string)$this->expires_at->value(),
|
||||
'used_at' => $this->used_at->value() === null ? null : (string)$this->used_at->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_command_jobs_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $command_type;
|
||||
public object_property $status;
|
||||
public object_property $request_json;
|
||||
public object_property $response_json;
|
||||
public object_property $delivery_json;
|
||||
public object_property $correlation_id;
|
||||
public object_property $requested_by;
|
||||
public object_property $requested_at;
|
||||
public object_property $completed_at;
|
||||
public object_property $error_message;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_command_jobs');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->command_type = new object_property($this->table, $this->id, 'command_type', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->request_json = new object_property($this->table, $this->id, 'request_json', 'json', false);
|
||||
$this->response_json = new object_property($this->table, $this->id, 'response_json', 'json', false);
|
||||
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
|
||||
$this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', false);
|
||||
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
||||
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
||||
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'command_type' => (string)$this->command_type->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'request' => (array)($this->request_json->value() ?? []),
|
||||
'response' => (array)($this->response_json->value() ?? []),
|
||||
'delivery' => (array)($this->delivery_json->value() ?? []),
|
||||
'correlation_id' => (string)$this->correlation_id->value(),
|
||||
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
||||
'requested_at' => (string)$this->requested_at->value(),
|
||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_device_inventory_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $device_id;
|
||||
public object_property $local_ip;
|
||||
public object_property $model;
|
||||
public object_property $channel_count;
|
||||
public object_property $capabilities_json;
|
||||
public object_property $online;
|
||||
public object_property $last_seen_at;
|
||||
public object_property $metadata_json;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_device_inventory');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->device_id = new object_property($this->table, $this->id, 'device_id', 'string', false);
|
||||
$this->local_ip = new object_property($this->table, $this->id, 'local_ip', 'string', false);
|
||||
$this->model = new object_property($this->table, $this->id, 'model', 'string', false);
|
||||
$this->channel_count = new object_property($this->table, $this->id, 'channel_count', 'int', false);
|
||||
$this->capabilities_json = new object_property($this->table, $this->id, 'capabilities_json', 'json', false);
|
||||
$this->online = new object_property($this->table, $this->id, 'online', 'bool', false);
|
||||
$this->last_seen_at = new object_property($this->table, $this->id, 'last_seen_at', 'string', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'device_id' => (string)$this->device_id->value(),
|
||||
'local_ip' => $this->local_ip->value() === null ? null : (string)$this->local_ip->value(),
|
||||
'model' => $this->model->value() === null ? null : (string)$this->model->value(),
|
||||
'channel_count' => (int)$this->channel_count->value(),
|
||||
'capabilities' => (array)($this->capabilities_json->value() ?? []),
|
||||
'online' => (bool)$this->online->value(),
|
||||
'last_seen_at' => $this->last_seen_at->value() === null ? null : (string)$this->last_seen_at->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_log_entries_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $level;
|
||||
public object_property $stream;
|
||||
public object_property $source;
|
||||
public object_property $message;
|
||||
public object_property $context_json;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_log_entries');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
|
||||
$this->stream = new object_property($this->table, $this->id, 'stream', 'string', false);
|
||||
$this->source = new object_property($this->table, $this->id, 'source', 'string', false);
|
||||
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
|
||||
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => $this->department_id->value() === null ? null : (int)$this->department_id->value(),
|
||||
'level' => (string)$this->level->value(),
|
||||
'stream' => (string)$this->stream->value(),
|
||||
'source' => (string)$this->source->value(),
|
||||
'message' => (string)$this->message->value(),
|
||||
'context' => (array)($this->context_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_operation_events_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $operation_id;
|
||||
public object_property $gateway_id;
|
||||
public object_property $stage;
|
||||
public object_property $level;
|
||||
public object_property $code;
|
||||
public object_property $message;
|
||||
public object_property $context_json;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_operation_events');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->operation_id = new object_property($this->table, $this->id, 'operation_id', 'int', false);
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->stage = new object_property($this->table, $this->id, 'stage', 'string', false);
|
||||
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
|
||||
$this->code = new object_property($this->table, $this->id, 'code', 'string', false);
|
||||
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
|
||||
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'operation_id' => (int)$this->operation_id->value(),
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'stage' => (string)$this->stage->value(),
|
||||
'level' => (string)$this->level->value(),
|
||||
'code' => $this->code->value() === null ? null : (string)$this->code->value(),
|
||||
'message' => (string)$this->message->value(),
|
||||
'context' => (array)($this->context_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_operations_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $type;
|
||||
public object_property $operation_type;
|
||||
public object_property $status;
|
||||
public object_property $request_json;
|
||||
public object_property $summary_json;
|
||||
public object_property $result_json;
|
||||
public object_property $error_code;
|
||||
public object_property $error_message;
|
||||
public object_property $correlation_id;
|
||||
public object_property $agent_instance_id;
|
||||
public object_property $lease_expires_at;
|
||||
public object_property $last_progress_at;
|
||||
public object_property $attempt_count;
|
||||
public object_property $requested_by;
|
||||
public object_property $requested_at;
|
||||
public object_property $started_at;
|
||||
public object_property $completed_at;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_operations');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->type = new object_property($this->table, $this->id, 'type', 'string', false);
|
||||
$this->operation_type = new object_property($this->table, $this->id, 'operation_type', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->request_json = new object_property($this->table, $this->id, 'request_json', 'json', false);
|
||||
$this->summary_json = new object_property($this->table, $this->id, 'summary_json', 'json', false);
|
||||
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false);
|
||||
$this->error_code = new object_property($this->table, $this->id, 'error_code', 'string', false);
|
||||
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false);
|
||||
$this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', false);
|
||||
$this->agent_instance_id = new object_property($this->table, $this->id, 'agent_instance_id', 'string', false);
|
||||
$this->lease_expires_at = new object_property($this->table, $this->id, 'lease_expires_at', 'string', false);
|
||||
$this->last_progress_at = new object_property($this->table, $this->id, 'last_progress_at', 'string', false);
|
||||
$this->attempt_count = new object_property($this->table, $this->id, 'attempt_count', 'int', false);
|
||||
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
||||
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
||||
$this->started_at = new object_property($this->table, $this->id, 'started_at', 'string', false);
|
||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'type' => (string)($this->type->value() ?? $this->operation_type->value() ?? ''),
|
||||
'status' => (string)$this->status->value(),
|
||||
'request' => (array)($this->request_json->value() ?? []),
|
||||
'summary' => (array)($this->summary_json->value() ?? []),
|
||||
'result' => (array)($this->result_json->value() ?? []),
|
||||
'error_code' => $this->error_code->value() === null ? null : (string)$this->error_code->value(),
|
||||
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
|
||||
'correlation_id' => (string)$this->correlation_id->value(),
|
||||
'agent_instance_id' => $this->agent_instance_id->value() === null ? null : (string)$this->agent_instance_id->value(),
|
||||
'lease_expires_at' => $this->lease_expires_at->value() === null ? null : (string)$this->lease_expires_at->value(),
|
||||
'last_progress_at' => $this->last_progress_at->value() === null ? null : (string)$this->last_progress_at->value(),
|
||||
'attempt_count' => (int)($this->attempt_count->value() ?? 0),
|
||||
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
||||
'requested_at' => (string)$this->requested_at->value(),
|
||||
'started_at' => $this->started_at->value() === null ? null : (string)$this->started_at->value(),
|
||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_relay_bindings_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $relay_id;
|
||||
public object_property $device_id;
|
||||
public object_property $local_ip;
|
||||
public object_property $channel;
|
||||
public object_property $binding_source;
|
||||
public object_property $approved_by;
|
||||
public object_property $approved_at;
|
||||
public object_property $metadata_json;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_relay_bindings');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->relay_id = new object_property($this->table, $this->id, 'relay_id', 'string', false);
|
||||
$this->device_id = new object_property($this->table, $this->id, 'device_id', 'string', false);
|
||||
$this->local_ip = new object_property($this->table, $this->id, 'local_ip', 'string', false);
|
||||
$this->channel = new object_property($this->table, $this->id, 'channel', 'int', false);
|
||||
$this->binding_source = new object_property($this->table, $this->id, 'binding_source', 'string', false);
|
||||
$this->approved_by = new object_property($this->table, $this->id, 'approved_by', 'int', false);
|
||||
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
$metadata = (array)($this->metadata_json->value() ?? []);
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'relay_id' => (string)$this->relay_id->value(),
|
||||
'device_id' => (string)$this->device_id->value(),
|
||||
'local_ip' => $this->local_ip->value() === null ? null : (string)$this->local_ip->value(),
|
||||
'channel' => (int)$this->channel->value(),
|
||||
'binding_source' => (string)$this->binding_source->value(),
|
||||
'approved_by' => $this->approved_by->value() === null ? null : (int)$this->approved_by->value(),
|
||||
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
|
||||
'metadata' => $metadata,
|
||||
'fallback_mode' => isset($metadata['fallback_mode']) ? (string)$metadata['fallback_mode'] : 'PREFER_LOCAL',
|
||||
'last_resolution' => isset($metadata['last_resolution']) && is_array($metadata['last_resolution'])
|
||||
? (array)$metadata['last_resolution']
|
||||
: null,
|
||||
'last_success_at' => isset($metadata['last_success_at']) ? (string)$metadata['last_success_at'] : null,
|
||||
'last_error' => isset($metadata['last_error']) ? (string)$metadata['last_error'] : null,
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_shell_sessions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $actor_user_id;
|
||||
public object_property $session_token_hash;
|
||||
public object_property $status;
|
||||
public object_property $reason;
|
||||
public object_property $connection_id;
|
||||
public object_property $cwd;
|
||||
public object_property $shell_command;
|
||||
public object_property $shell_args_json;
|
||||
public object_property $cols;
|
||||
public object_property $rows;
|
||||
public object_property $transcript;
|
||||
public object_property $metadata_json;
|
||||
public object_property $expires_at;
|
||||
public object_property $approved_at;
|
||||
public object_property $opened_at;
|
||||
public object_property $closed_at;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_shell_sessions');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->actor_user_id = new object_property($this->table, $this->id, 'actor_user_id', 'int', false);
|
||||
$this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->reason = new object_property($this->table, $this->id, 'reason', 'string', false);
|
||||
$this->connection_id = new object_property($this->table, $this->id, 'connection_id', 'string', false);
|
||||
$this->cwd = new object_property($this->table, $this->id, 'cwd', 'string', false);
|
||||
$this->shell_command = new object_property($this->table, $this->id, 'shell_command', 'string', false);
|
||||
$this->shell_args_json = new object_property($this->table, $this->id, 'shell_args_json', 'json', false);
|
||||
$this->cols = new object_property($this->table, $this->id, 'cols', 'int', false);
|
||||
$this->rows = new object_property($this->table, $this->id, 'terminal_rows', 'int', false);
|
||||
$this->transcript = new object_property($this->table, $this->id, 'transcript', 'text', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
|
||||
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
|
||||
$this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false);
|
||||
$this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'actor_user_id' => $this->actor_user_id->value() === null ? null : (int)$this->actor_user_id->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'reason' => $this->reason->value() === null ? null : (string)$this->reason->value(),
|
||||
'connection_id' => $this->connection_id->value() === null ? null : (string)$this->connection_id->value(),
|
||||
'cwd' => $this->cwd->value() === null ? null : (string)$this->cwd->value(),
|
||||
'shell_command' => $this->shell_command->value() === null ? null : (string)$this->shell_command->value(),
|
||||
'shell_args' => (array)($this->shell_args_json->value() ?? []),
|
||||
'cols' => $this->cols->value() === null ? null : (int)$this->cols->value(),
|
||||
'rows' => $this->rows->value() === null ? null : (int)$this->rows->value(),
|
||||
'transcript' => $this->transcript->value() === null ? null : (string)$this->transcript->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'expires_at' => $this->expires_at->value() === null ? null : (string)$this->expires_at->value(),
|
||||
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
|
||||
'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(),
|
||||
'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateways_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $department_id;
|
||||
public object_property $label;
|
||||
public object_property $hostname;
|
||||
public object_property $agent_token_hash;
|
||||
public object_property $status;
|
||||
public object_property $transport_mode;
|
||||
public object_property $release_channel;
|
||||
public object_property $installed_version;
|
||||
public object_property $target_version;
|
||||
public object_property $last_heartbeat_at;
|
||||
public object_property $last_seen_ip;
|
||||
public object_property $discovery_status;
|
||||
public object_property $is_primary;
|
||||
public object_property $metadata_json;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateways');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->label = new object_property($this->table, $this->id, 'label', 'string', false);
|
||||
$this->hostname = new object_property($this->table, $this->id, 'hostname', 'string', false);
|
||||
$this->agent_token_hash = new object_property($this->table, $this->id, 'agent_token_hash', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->transport_mode = new object_property($this->table, $this->id, 'transport_mode', 'string', false);
|
||||
$this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false);
|
||||
$this->installed_version = new object_property($this->table, $this->id, 'installed_version', 'string', false);
|
||||
$this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false);
|
||||
$this->last_heartbeat_at = new object_property($this->table, $this->id, 'last_heartbeat_at', 'string', false);
|
||||
$this->last_seen_ip = new object_property($this->table, $this->id, 'last_seen_ip', 'string', false);
|
||||
$this->discovery_status = new object_property($this->table, $this->id, 'discovery_status', 'string', false);
|
||||
$this->is_primary = new object_property($this->table, $this->id, 'is_primary', 'bool', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'label' => (string)$this->label->value(),
|
||||
'hostname' => $this->hostname->value() === null ? null : (string)$this->hostname->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'transport_mode' => (string)$this->transport_mode->value(),
|
||||
'release_channel' => (string)$this->release_channel->value(),
|
||||
'installed_version' => $this->installed_version->value() === null ? null : (string)$this->installed_version->value(),
|
||||
'target_version' => $this->target_version->value() === null ? null : (string)$this->target_version->value(),
|
||||
'last_heartbeat_at' => $this->last_heartbeat_at->value() === null ? null : (string)$this->last_heartbeat_at->value(),
|
||||
'last_seen_ip' => $this->last_seen_ip->value() === null ? null : (string)$this->last_seen_ip->value(),
|
||||
'discovery_status' => (string)$this->discovery_status->value(),
|
||||
'is_primary' => (bool)$this->is_primary->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use classes\db;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
use Throwable;
|
||||
|
||||
class groups_permissions_o extends db
|
||||
{
|
||||
@@ -48,10 +49,11 @@ class groups_permissions_o extends db
|
||||
]);
|
||||
$this->id = $tmp_id;
|
||||
self::getObjectProperties();
|
||||
self::objectChanged();
|
||||
if (!$this->id) {
|
||||
throw new Exception('The permission was not created successfully.');
|
||||
}
|
||||
$this->invalidateGroupSessionCaches($group_id);
|
||||
self::objectChanged();
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
@@ -62,7 +64,7 @@ class groups_permissions_o extends db
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
// Cache invalidation is handled in add/remove where group context is guaranteed.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +98,58 @@ class groups_permissions_o extends db
|
||||
$tmp_id->select((int)$id);
|
||||
$tmp_id->requireSelected();
|
||||
$tmp_id->delete();
|
||||
$this->invalidateGroupSessionCaches($group_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached permissions and session payloads for users associated with a group.
|
||||
*/
|
||||
private function invalidateGroupSessionCaches(int $group_id): void
|
||||
{
|
||||
if ($group_id <= 0 || !defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$userRows = (new users_o())->getFieldsWhere([
|
||||
'group_id' => $group_id,
|
||||
], ['id']);
|
||||
|
||||
if (count($userRows) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
foreach ($userRows as $userRow) {
|
||||
$id = (int)($userRow['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$userIds[] = $id;
|
||||
}
|
||||
}
|
||||
$userIds = array_values(array_unique($userIds));
|
||||
|
||||
if (count($userIds) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
redis->clear_keys('perm:user:' . $userId . ':*');
|
||||
}
|
||||
|
||||
$tokenRows = (new tokens_o())->getFieldsWhere([
|
||||
'user_id' => $userIds,
|
||||
], ['token']);
|
||||
|
||||
foreach ($tokenRows as $tokenRow) {
|
||||
$token = (string)($tokenRow['token'] ?? '');
|
||||
if ($token === '') {
|
||||
continue;
|
||||
}
|
||||
redis->clear_auth_session($token);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Cache invalidation must not block permission updates.
|
||||
}
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
@@ -145,4 +199,4 @@ class groups_permissions_o extends db
|
||||
return preg_match($regex, $permission['permission']);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use classes\db;
|
||||
use classes\email;
|
||||
use classes\gatewayapi;
|
||||
use classes\object_property;
|
||||
use classes\order_bookings_counts_cache;
|
||||
use classes\order_bookings_list_cache;
|
||||
use classes\pdf_generator;
|
||||
use classes\slack;
|
||||
use Exception;
|
||||
@@ -290,7 +292,12 @@ class order_bookings_o extends db
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
try {
|
||||
order_bookings_list_cache::clearAll();
|
||||
order_bookings_counts_cache::clearAll();
|
||||
} catch (\Throwable) {
|
||||
// Cache invalidation must never break order-booking writes.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -347,19 +354,36 @@ class order_bookings_o extends db
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function completeBooking(int $user_id, string $safety_seal = null): void
|
||||
public function completeBooking(int $user_id, ?string $safety_seal = null): void
|
||||
{
|
||||
self::requireSelected();
|
||||
if (!$this->order_id->value()) {
|
||||
// Create order, if not already created
|
||||
self::createOrderBy($user_id);
|
||||
$this->createOrderBy($user_id);
|
||||
// Add order items, re-calculate the prices to be customer-specific
|
||||
self::createOrderItemsBy($user_id);
|
||||
$this->createOrderItemsBy($user_id);
|
||||
}
|
||||
|
||||
$order = $this->getOrder();
|
||||
if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requireLinkedOrderMatchesBooking($order);
|
||||
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
||||
if ($normalizedSafetySeal !== null) {
|
||||
$order->setSafetySealValue($normalizedSafetySeal);
|
||||
$order->objectChanged();
|
||||
}
|
||||
|
||||
if ($order->hasWashCertificateAttached()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
|
||||
if ($order->hasWashCertificateAttached()) {
|
||||
$this->sendWashCertificateToCustomer();
|
||||
}
|
||||
// Create a wash certificate (If applicable)
|
||||
if (self::containsWashCertificateItem()) self::attachWashCertificate($user_id, $safety_seal);
|
||||
// Send wash certificate
|
||||
self::sendWashCertificateToCustomer();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,11 +415,16 @@ class order_bookings_o extends db
|
||||
continue;
|
||||
}
|
||||
$orderItems = new order_items_o();
|
||||
$itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== ''
|
||||
? (string)$item['notes']
|
||||
: ((string)($this->note->value() ?? '') ?: null);
|
||||
$orderItems->addItemToOrder(
|
||||
(int)$order->id,
|
||||
(int)$item['id'],
|
||||
(int)$user_id,
|
||||
(int)$item['quantity'],
|
||||
null,
|
||||
$itemNotes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -407,21 +436,18 @@ class order_bookings_o extends db
|
||||
public function containsWashCertificateItem(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
return self::containsProductId(41);
|
||||
}
|
||||
foreach ($this->items->value() as $item) {
|
||||
$product_id = (int)($item['id'] ?? 0);
|
||||
if ($product_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function containsProductId(int $productId): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
$items = $this->items->value();
|
||||
foreach ($items as $item) {
|
||||
if (isset($item['id']) && (int)$item['id'] == $productId) {
|
||||
$product = (new products_o())->select($product_id);
|
||||
if ($product->exists() && $product->isWashCertificate()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -445,11 +471,31 @@ class order_bookings_o extends db
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function attachWashCertificate(int $user_id, string $safety_seal = null): void
|
||||
private function requireLinkedOrderMatchesBooking(orders_o $order): void
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$bookingCustomerNumber = (int)$this->customer_number->value();
|
||||
$bookingDepartmentId = (int)$this->department->value();
|
||||
$orderCustomerId = (int)$order->customer_id->value();
|
||||
$orderDepartmentId = (int)$order->department_id->value();
|
||||
|
||||
if ($orderCustomerId !== $bookingCustomerNumber || $orderDepartmentId !== $bookingDepartmentId) {
|
||||
throw new Exception('Linked order does not match booking customer or department');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void
|
||||
{
|
||||
self::requireSelected();
|
||||
$order = $this->getOrder();
|
||||
$this->requireLinkedOrderMatchesBooking($order);
|
||||
|
||||
// Check if the order already has a wash certificate attached
|
||||
if (self::getOrder()->hasWashCertificateAttached()) {
|
||||
if ($order->hasWashCertificateAttached()) {
|
||||
return;
|
||||
}
|
||||
// Get the operator name
|
||||
@@ -458,7 +504,7 @@ class order_bookings_o extends db
|
||||
throw new Exception('Operator not found');
|
||||
}
|
||||
// Generate wash certificate
|
||||
self::generateWashCertificate($safety_seal, $operator->display_name->value());
|
||||
$this->generateWashCertificate($safety_seal, $operator->display_name->value());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -468,7 +514,7 @@ class order_bookings_o extends db
|
||||
* @throws Exception If the object is not selected
|
||||
* @throws Exception If the booking already has a wash certificate
|
||||
*/
|
||||
public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null): void
|
||||
public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null): void
|
||||
{
|
||||
self::requireSelected();
|
||||
// Generate the wash certificate
|
||||
@@ -510,7 +556,7 @@ class order_bookings_o extends db
|
||||
])
|
||||
->addData([
|
||||
'booking_number' => $this->id,
|
||||
'seal_number' => ($safety_seal ?? null),
|
||||
'seal_number' => orders_o::normalizeSafetySealValue($safety_seal),
|
||||
'reg_1' => $booking_array['reg_1'],
|
||||
'reg_2' => $booking_array['reg_2'],
|
||||
'date' => date('d-m-Y'),
|
||||
@@ -556,4 +602,68 @@ class order_bookings_o extends db
|
||||
return count($order_ids);
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* @param array<int, int|string>|null $departmentIds
|
||||
* @return array{past:int,current:int,future:int}
|
||||
*/
|
||||
public function getPendingBookingCounts(?array $departmentIds = null, ?int $customerNumber = null, ?\DateTimeImmutable $reference = null): array
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
|
||||
$normalizedDepartmentIds = [];
|
||||
if (is_array($departmentIds)) {
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$normalizedDepartmentId = (int)$departmentId;
|
||||
if ($normalizedDepartmentId > 0) {
|
||||
$normalizedDepartmentIds[] = $normalizedDepartmentId;
|
||||
}
|
||||
}
|
||||
$normalizedDepartmentIds = array_values(array_unique($normalizedDepartmentIds));
|
||||
if ($normalizedDepartmentIds === []) {
|
||||
return [
|
||||
'past' => 0,
|
||||
'current' => 0,
|
||||
'future' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$now = $reference ?? new \DateTimeImmutable('now');
|
||||
$todayStart = $now->setTime(0, 0, 0);
|
||||
$todayEnd = $now->setTime(23, 59, 59);
|
||||
|
||||
$todayStartSql = $db->escape_string($todayStart->format('Y-m-d H:i:s'));
|
||||
$todayEndSql = $db->escape_string($todayEnd->format('Y-m-d H:i:s'));
|
||||
|
||||
$whereClauses = [
|
||||
'`deleted_at` IS NULL',
|
||||
"(`order_id` IS NULL OR `order_id` = 0 OR TRIM(CAST(`order_id` AS CHAR)) = '')",
|
||||
];
|
||||
|
||||
if ($normalizedDepartmentIds !== []) {
|
||||
$whereClauses[] = '`department` IN (' . implode(', ', array_map('intval', $normalizedDepartmentIds)) . ')';
|
||||
}
|
||||
|
||||
if ($customerNumber !== null && $customerNumber > 0) {
|
||||
$whereClauses[] = '`customer_number` = ' . (int)$customerNumber;
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
COALESCE(SUM(CASE WHEN `datetime` < '{$todayStartSql}' THEN 1 ELSE 0 END), 0) AS `past`,
|
||||
COALESCE(SUM(CASE WHEN `datetime` >= '{$todayStartSql}' AND `datetime` <= '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `current`,
|
||||
COALESCE(SUM(CASE WHEN `datetime` > '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `future`
|
||||
FROM `order_bookings`
|
||||
WHERE " . implode(' AND ', $whereClauses);
|
||||
|
||||
$result = $db->query($sql);
|
||||
$row = $db->fetch_assoc($result);
|
||||
|
||||
return [
|
||||
'past' => max(0, (int)($row['past'] ?? 0)),
|
||||
'current' => max(0, (int)($row['current'] ?? 0)),
|
||||
'future' => max(0, (int)($row['future'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ class order_items_o extends db
|
||||
}
|
||||
}
|
||||
|
||||
public function addItemToOrder(int $order_id, int $product_id, int $cashier_id, int $quantity, $related_item_id = null, $notes = null, $forcePrice = null): void
|
||||
public function addItemToOrder(int $order_id, int $product_id, int $cashier_id, int $quantity, $related_item_id = null, $notes = null, $forcePrice = null): order_items_o
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
@@ -199,6 +199,7 @@ class order_items_o extends db
|
||||
}
|
||||
// Invalidate the order cache
|
||||
$order->objectChanged();
|
||||
return (new order_items_o())->select($this->id);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$response->error($e->getMessage());
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace objects;
|
||||
use attachments\helpers\attachment_content;
|
||||
use classes\db;
|
||||
use classes\email;
|
||||
use classes\invoicing_period_utils;
|
||||
use classes\orders_schema_bootstrap;
|
||||
use classes\pdf_generator;
|
||||
use classes\motorapi;
|
||||
use classes\object_property;
|
||||
@@ -30,6 +32,7 @@ class orders_o extends db
|
||||
public object_property $reg_3;
|
||||
public economic_module_orders $economic_module_orders;
|
||||
public object_property $created_at;
|
||||
public object_property $include_in_invoice;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public object_property $completed_at;
|
||||
@@ -39,6 +42,7 @@ class orders_o extends db
|
||||
public object_property $wash_id; // The XL Vask Wash ID, if any
|
||||
public object_property $lane; // The lane used for the order, if any
|
||||
public object_property $po; // The (optional) PO number, filled by the customer.
|
||||
public object_property $safety_seal; // The optional safety seal value for wash certificates.
|
||||
public object_property $using_hand_held; // Whether the order is being processed using a handheld device
|
||||
|
||||
/**
|
||||
@@ -52,6 +56,7 @@ class orders_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
orders_schema_bootstrap::ensureTables();
|
||||
$this->setTable('orders');
|
||||
}
|
||||
|
||||
@@ -85,6 +90,7 @@ class orders_o extends db
|
||||
$this->reg_2 = new object_property($this->table, $this->id, 'reg_2', 'string', false);
|
||||
$this->reg_3 = new object_property($this->table, $this->id, 'reg_3', 'string', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
|
||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'timestamp', false);
|
||||
$this->economic_module_orders = (new economic_module_orders())->getByOrderId($this->id);
|
||||
$this->stripe_module_orders = (new stripe_module_orders_o())->select($this->id);
|
||||
@@ -94,6 +100,7 @@ class orders_o extends db
|
||||
$this->wash_id = new object_property($this->table, $this->id, 'wash_id', 'string', false);
|
||||
$this->lane = new object_property($this->table, $this->id, 'lane', 'string', false);
|
||||
$this->po = new object_property($this->table, $this->id, 'po', 'string', false);
|
||||
$this->safety_seal = new object_property($this->table, $this->id, 'safety_seal', 'string', false);
|
||||
$this->using_hand_held = new object_property($this->table, $this->id, 'using_hand_held', 'bool', false);
|
||||
}
|
||||
|
||||
@@ -147,6 +154,80 @@ class orders_o extends db
|
||||
// Save the object
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the data needed to decide whether deletion needs explicit confirmation.
|
||||
*
|
||||
* @return array{
|
||||
* requires_confirmation: bool,
|
||||
* protected_reasons: array<int, string>,
|
||||
* order_item_count: int,
|
||||
* attachment_count: int,
|
||||
* completed_at: mixed
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getDeleteProtectionSummary(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$completedAt = $this->completed_at->value();
|
||||
$orderItemCount = $this->countActiveOrderItems();
|
||||
$attachmentCount = $this->countActiveOrderAttachments();
|
||||
$protectedReasons = [];
|
||||
|
||||
if ($completedAt !== null) {
|
||||
$protectedReasons[] = 'completed';
|
||||
}
|
||||
if ($orderItemCount > 0) {
|
||||
$protectedReasons[] = 'order_items';
|
||||
}
|
||||
if ($attachmentCount > 0) {
|
||||
$protectedReasons[] = 'attachments';
|
||||
}
|
||||
|
||||
return [
|
||||
'requires_confirmation' => count($protectedReasons) > 0,
|
||||
'protected_reasons' => $protectedReasons,
|
||||
'order_item_count' => $orderItemCount,
|
||||
'attachment_count' => $attachmentCount,
|
||||
'completed_at' => $completedAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function countActiveOrderItems(): int
|
||||
{
|
||||
self::requireSelected();
|
||||
global $db;
|
||||
|
||||
$orderId = (int)$this->id;
|
||||
$result = $db->query("SELECT COUNT(*) AS total FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL");
|
||||
if ($result && $row = $result->fetch_assoc()) {
|
||||
return (int)($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function countActiveOrderAttachments(): int
|
||||
{
|
||||
self::requireSelected();
|
||||
global $db;
|
||||
|
||||
$orderId = (int)$this->id;
|
||||
$result = $db->query("SELECT COUNT(*) AS total FROM object_attachments WHERE object_type = 'orders' AND object_id = {$orderId} AND deleted_at IS NULL");
|
||||
if ($result && $row = $result->fetch_assoc()) {
|
||||
return (int)($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception If the order is not selected
|
||||
* This function is called when the order object is changed.
|
||||
@@ -262,10 +343,8 @@ class orders_o extends db
|
||||
* @throws Exception If the order is not selected
|
||||
* @throws Exception If the order is already completed
|
||||
*/
|
||||
public function markAsCompleted(): void
|
||||
public function markAsCompleted(string|null $operator = null): void
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
self::requireSelected();
|
||||
// Check if the order is already completed
|
||||
if ($this->completed_at->value() !== null) {
|
||||
@@ -273,9 +352,16 @@ class orders_o extends db
|
||||
}
|
||||
// Set the completed_at property to the current timestamp
|
||||
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||
$sql = "UPDATE $this->table SET completed_at = '" . $this->completed_at->value() . "' WHERE id = " . $this->id;
|
||||
$db->query($sql);
|
||||
$this->setPendingHandheldIndicator(false);
|
||||
$washCertificateCreated = $this->completeWashCertificateIfNeeded(
|
||||
$operator,
|
||||
(string)$this->completed_at->value()
|
||||
);
|
||||
$this->objectChanged();
|
||||
|
||||
if ($washCertificateCreated && (int)$this->booking_id->value() > 0) {
|
||||
$this->getOrderBooking()?->sendWashCertificateToCustomer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,6 +393,16 @@ class orders_o extends db
|
||||
self::objectChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function clearStripeInvoicing(): void
|
||||
{
|
||||
self::requireSelected();
|
||||
$this->stripe_module_orders->clear();
|
||||
self::objectChanged();
|
||||
}
|
||||
|
||||
public function addArray(array $order_array): orders_o
|
||||
{
|
||||
global $db, $response;
|
||||
@@ -364,10 +460,11 @@ class orders_o extends db
|
||||
* @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used.
|
||||
* @throws Exception If the order is not selected
|
||||
*/
|
||||
public function assignToInvoiceCollection(int $invoiceCollectionId = null): void
|
||||
public function assignToInvoiceCollection(int $invoiceCollectionId = null, bool $notifyChanges = true): void
|
||||
{
|
||||
// If the invoice collection id is not set, get the default invoice collection id
|
||||
self::requireSelected();
|
||||
$previousInvoiceCollectionId = (int)$this->invoice_collection_id->value();
|
||||
// Get the customer
|
||||
$customer = new users_o();
|
||||
$customer_id = (int)$this->customer_id->value();
|
||||
@@ -380,7 +477,16 @@ class orders_o extends db
|
||||
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
|
||||
// Assign the order to the invoice collection
|
||||
$this->invoice_collection_id->set($invoiceCollectionId);
|
||||
self::objectChanged();
|
||||
if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) {
|
||||
$previousCollection = new collected_order_invoices_o();
|
||||
$previousCollection->select($previousInvoiceCollectionId);
|
||||
if ($previousCollection->exists()) {
|
||||
$previousCollection->objectChanged();
|
||||
}
|
||||
}
|
||||
if ($notifyChanges) {
|
||||
self::objectChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -568,6 +674,8 @@ class orders_o extends db
|
||||
'reg_3' => $this->reg_3->value(),
|
||||
'completed_at' => $this->completed_at->value(),
|
||||
'created_at' => $this->created_at->value(),
|
||||
'include_in_invoice' => $this->getIncludeInInvoiceOverride(),
|
||||
'include_in_invoice_effective' => $this->isIncludedInInvoicing(),
|
||||
'deleted_at' => $this->deleted_at->value(),
|
||||
'total_net_amount' => $this->temporary_net_amount ?: $this->getNetAmount(),
|
||||
'invoice_collection_id' => (int)$this->invoice_collection_id->value(),
|
||||
@@ -575,6 +683,7 @@ class orders_o extends db
|
||||
'wash_id' => $this->wash_id->value(),
|
||||
'lane' => $this->lane->value(),
|
||||
'po' => $this->po->value(),
|
||||
'safety_seal' => $this->getSafetySealValue(),
|
||||
'closed_at' => (int)$this->invoice_collection_id->value() ? (new collected_order_invoices_o())->select((int)$this->invoice_collection_id->value())->closed_at->value() : null,
|
||||
'pending_handheld' => $this->isPendingHandheld(),
|
||||
];
|
||||
@@ -620,6 +729,10 @@ class orders_o extends db
|
||||
*/
|
||||
public function getNetAmountForOrders(array $order_ids): array
|
||||
{
|
||||
if (empty($order_ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$order_items = new order_items_o();
|
||||
$tmp = $order_items->getFieldsWhere(
|
||||
[
|
||||
@@ -1054,6 +1167,9 @@ class orders_o extends db
|
||||
public function getTransactionsForCustomersInDateRange(array $customers, string $dateFrom, string $dateTo): array
|
||||
{
|
||||
global $db;
|
||||
if (empty($customers)) {
|
||||
return [];
|
||||
}
|
||||
// Validate the date range
|
||||
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
@@ -1079,6 +1195,141 @@ class orders_o extends db
|
||||
return $transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get period transactions as plain rows grouped by customer number.
|
||||
*
|
||||
* This avoids hydrating one orders_o object per order for the invoicing period response.
|
||||
*
|
||||
* @param int[]|null $customers Null means all local customers with orders in the period.
|
||||
* @return array<int, array<int, array<string,mixed>>>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array
|
||||
{
|
||||
global $db;
|
||||
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
if (strtotime($dateFrom) > strtotime($dateTo)) {
|
||||
throw new Exception('The start date cannot be after the end date');
|
||||
}
|
||||
|
||||
$customerFilter = '';
|
||||
if ($customers !== null) {
|
||||
$customers = array_values(array_unique(array_filter(
|
||||
array_map('intval', $customers),
|
||||
static fn(int $customerNumber): bool => $customerNumber > 0
|
||||
)));
|
||||
if (empty($customers)) {
|
||||
return [];
|
||||
}
|
||||
$customerFilter = ' AND o.customer_id IN (' . implode(',', $customers) . ')';
|
||||
}
|
||||
|
||||
$dateFrom = $db->escape_string($dateFrom);
|
||||
$dateTo = $db->escape_string($dateTo);
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id,
|
||||
o.customer_id AS customer_number,
|
||||
customer_user.user_id,
|
||||
customer_user.customer_name,
|
||||
o.created_at,
|
||||
COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount,
|
||||
CASE
|
||||
WHEN COALESCE(o.invoice_collection_id, 0) > 0
|
||||
THEN CASE WHEN COALESCE(coi.booked_invoice_id, 0) <> 0 THEN 1 ELSE 0 END
|
||||
ELSE CASE WHEN COALESCE(emo.invoice_id, 0) <> 0 THEN 1 ELSE 0 END
|
||||
END AS booked,
|
||||
o.department_id,
|
||||
o.reference,
|
||||
o.po,
|
||||
o.notes,
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.invoice_collection_id,
|
||||
CASE
|
||||
WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice
|
||||
WHEN COALESCE(department_flags.exclude_from_invoicing, 0) = 1 THEN 0
|
||||
ELSE 1
|
||||
END AS include_in_invoice_effective
|
||||
FROM {$this->table} o
|
||||
INNER JOIN (
|
||||
SELECT customer_number, MIN(id) AS user_id, MAX(display_name) AS customer_name
|
||||
FROM users
|
||||
WHERE customer_number IS NOT NULL AND customer_number <> 0
|
||||
GROUP BY customer_number
|
||||
) customer_user ON customer_user.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id
|
||||
LEFT JOIN economic_module_orders emo ON emo.id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT department_id, MAX(value = 'true') AS exclude_from_invoicing
|
||||
FROM department_variables
|
||||
WHERE variable = 'exclude_from_invoicing'
|
||||
GROUP BY department_id
|
||||
) department_flags ON department_flags.department_id = o.department_id
|
||||
WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}'
|
||||
AND o.deleted_at IS NULL
|
||||
{$customerFilter}
|
||||
GROUP BY
|
||||
o.id,
|
||||
o.customer_id,
|
||||
customer_user.user_id,
|
||||
customer_user.customer_name,
|
||||
o.created_at,
|
||||
coi.booked_invoice_id,
|
||||
emo.invoice_id,
|
||||
o.department_id,
|
||||
o.reference,
|
||||
o.po,
|
||||
o.notes,
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.invoice_collection_id,
|
||||
o.include_in_invoice,
|
||||
department_flags.exclude_from_invoicing
|
||||
ORDER BY o.customer_id, o.created_at, o.id";
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$transactions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
if ($customerNumber < 1) {
|
||||
continue;
|
||||
}
|
||||
$invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0);
|
||||
$transactions[$customerNumber][] = [
|
||||
'id' => (int)$row['id'],
|
||||
'date' => (string)($row['created_at'] ?? ''),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
'amount' => (float)($row['net_amount'] ?? 0),
|
||||
'booked' => (int)($row['booked'] ?? 0) === 1,
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'customer_number' => $customerNumber,
|
||||
'reference' => (string)($row['reference'] ?? ''),
|
||||
'po' => (string)($row['po'] ?? ''),
|
||||
'notes' => (string)($row['notes'] ?? ''),
|
||||
'reg_1' => (string)($row['reg_1'] ?? ''),
|
||||
'reg_2' => (string)($row['reg_2'] ?? ''),
|
||||
'reg_3' => (string)($row['reg_3'] ?? ''),
|
||||
'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1,
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'queue_status' => null,
|
||||
'queue_job_id' => null,
|
||||
'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null,
|
||||
'customer_name' => (string)($row['customer_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders with possible duplicates in a date range
|
||||
* @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format
|
||||
@@ -1127,36 +1378,8 @@ class orders_o extends db
|
||||
'object' => (new orders_o())->select((int)$tmp['id'])
|
||||
];
|
||||
}
|
||||
// Filter out orders with more than one entry for the same registration numbers (in a 24 hour period)
|
||||
$possibleDuplicates = [];
|
||||
// Loop through the registration numbers
|
||||
foreach ( $orders as $reg_1 => $orderList ) {
|
||||
// If there are more than one order for the same registration number, add it to the possible duplicates
|
||||
if (count($orderList) > 1) {
|
||||
|
||||
// Loop through the orders and check if they are within 24 hours of each other
|
||||
$filteredOrders = [];
|
||||
foreach ( $orderList as $order ) {
|
||||
// Check if the order is within 24 hours of the previous order (if any)
|
||||
if (empty($filteredOrders)) {
|
||||
$filteredOrders[] = $order; // Add the first order
|
||||
} else {
|
||||
// Check if the order is within 24 hours of the previous order
|
||||
$firstOrderTime = strtotime($filteredOrders[0]['created_at']);
|
||||
$currentOrderTime = strtotime($order['created_at']);
|
||||
if ($currentOrderTime - $firstOrderTime <= 86400) { // 86400 seconds = 24 hours
|
||||
$filteredOrders[] = $order; // Add the order to the filtered list
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there are more than one order in the filtered list, add it to the possible duplicates
|
||||
if (count($filteredOrders) > 1) {
|
||||
$possibleDuplicates[$reg_1] = $filteredOrders;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return the possible duplicates
|
||||
return $possibleDuplicates;
|
||||
return invoicing_period_utils::filterPossibleDuplicates($orders, 86400);
|
||||
}
|
||||
|
||||
public function setTemporaryNetAmount(float $amount): void
|
||||
@@ -1333,31 +1556,42 @@ class orders_o extends db
|
||||
{
|
||||
// Get the original net amount for the order items, ignoring any temporary net amount set
|
||||
self::requireSelected();
|
||||
$order_items = $this->getOrderItems((int)$this->id);
|
||||
//echo 'Calculating net amount for order ID ' . $this->id . ' with ' . count($order_items) . " items\n";
|
||||
return array_sum(array_map(/**
|
||||
* @throws Exception
|
||||
*/ function ($item) {
|
||||
if ( (int)$item['price'] > 0 && (int)$item['quantity'] > 0) {
|
||||
return (int)$item['price'] * (int)$item['quantity'];
|
||||
$order_items = (new order_items_o())->getFieldsWhere(
|
||||
['order_id' => (int)$this->id],
|
||||
['price', 'quantity', 'product_id']
|
||||
);
|
||||
if (empty($order_items)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
$department_id = (int)$this->department_id->value();
|
||||
$tmp_user = null;
|
||||
$department_price_cache = [];
|
||||
|
||||
foreach ( $order_items as $item ) {
|
||||
$price = (int)$item['price'];
|
||||
$quantity = (int)$item['quantity'];
|
||||
|
||||
if ($price > 0 && $quantity > 0) {
|
||||
$total += $price * $quantity;
|
||||
continue;
|
||||
}
|
||||
// Otherwise we need to get the product price
|
||||
$product = (new products_o())->select((int)$item['product_id']);
|
||||
// Apply the customer discount if applicable
|
||||
$department_price_original = (int)$product->getDepartmentPrice((int)$this->department_id->value());
|
||||
// Get the customers user object
|
||||
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||
// Get the discount percentage for the customer
|
||||
$discount = $tmp_user->getCustomPrice((int)$product->id, false);
|
||||
// Calculate the final price after discount
|
||||
$post_discount = (int)round($department_price_original * (1 - ($discount / 100))) * (int)$item['quantity'];
|
||||
//echo 'Adding ' . $post_discount . ' for product ' . $product->name . ' (Original price: ' . $department_price_original . ', Discount: ' . $discount . '%, Quantity: ' . (int)$item['quantity'] . ")\n";
|
||||
$actual_price = (int)$item['price'];
|
||||
if ($actual_price !== $post_discount) {
|
||||
//echo "Warning: The actual price ($actual_price) does not match the calculated price ($post_discount) for product " . $product->name . "\n";
|
||||
|
||||
$product_id = (int)$item['product_id'];
|
||||
if (!isset($department_price_cache[$product_id])) {
|
||||
$product = (new products_o())->select($product_id);
|
||||
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
|
||||
}
|
||||
return $post_discount;
|
||||
}, $order_items));
|
||||
if ($tmp_user === null) {
|
||||
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||
}
|
||||
$discount = $tmp_user->getCustomPrice($product_id, false);
|
||||
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
|
||||
$total += $post_discount;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1375,24 +1609,212 @@ class orders_o extends db
|
||||
public function hasWashCertificateAttached(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
// Check if the order has a wash certificate attached
|
||||
$attachments = $this->listAttachments();
|
||||
foreach ( $attachments as $attachment ) {
|
||||
if ($attachment->isWashCertificate()) {
|
||||
return true; // Wash certificate found
|
||||
return $this->listWashCertificateAttachmentIds() !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function listWashCertificateAttachmentIds(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
global $db;
|
||||
|
||||
$rawObjectType = trim((string)$this->table, '`');
|
||||
$objectTypes = array_values(array_unique([
|
||||
$db->escape_string($rawObjectType),
|
||||
$db->escape_string('`' . $rawObjectType . '`'),
|
||||
]));
|
||||
$quotedObjectTypes = "'" . implode("','", $objectTypes) . "'";
|
||||
$objectId = (int)$this->id;
|
||||
$sql = "SELECT id, content
|
||||
FROM object_attachments
|
||||
WHERE object_type IN ($quotedObjectTypes)
|
||||
AND object_id = $objectId
|
||||
AND deleted_at IS NULL";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$attachmentIds = [];
|
||||
while ($row = $db->fetch_assoc($result)) {
|
||||
$content = json_decode((string)($row['content'] ?? ''), true);
|
||||
$other = is_array($content) ? ($content['other'] ?? null) : null;
|
||||
if (is_string($other) && strtolower($other) === 'wash_certificate') {
|
||||
$attachmentIds[] = (int)($row['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
return false; // No wash certificate product found in the order items
|
||||
|
||||
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function regenerateAttachedWashCertificate(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
if (!$this->hasWashCertificateAttached()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->removeAttachedWashCertificates();
|
||||
$this->generateWashCertificate(
|
||||
$this->getSafetySealValue(),
|
||||
$this->resolveWashCertificateOperator(),
|
||||
$this->resolveWashCertificateDate()
|
||||
);
|
||||
|
||||
return $this->hasWashCertificateAttached();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function removeAttachedWashCertificates(): int
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$attachmentIds = $this->listWashCertificateAttachmentIds();
|
||||
if ($attachmentIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$escapedIds = array_map(static fn(int $id): int => (int)$id, $attachmentIds);
|
||||
$idList = implode(',', $escapedIds);
|
||||
$sql = "UPDATE object_attachments
|
||||
SET deleted_at = NOW()
|
||||
WHERE id IN ($idList)
|
||||
AND deleted_at IS NULL";
|
||||
$db->query($sql);
|
||||
|
||||
return count($escapedIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function containsWashCertificateItem(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
$products = new products_o();
|
||||
|
||||
foreach ($this->getOrderItems((int)$this->id) as $item) {
|
||||
$product_id = (int)($item['product_id'] ?? 0);
|
||||
if ($product_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$product = $products->select($product_id);
|
||||
if ($product->exists() && $product->isWashCertificate()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function normalizeSafetySealValue(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$normalized = trim($value);
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getSafetySealValue(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
return self::normalizeSafetySealValue($this->safety_seal->value());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resolveWashCertificateOperator(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$cashierId = (int)$this->cashier_id->value();
|
||||
if ($cashierId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cashier = (new users_o())->select($cashierId);
|
||||
if (!$cashier->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$displayName = trim((string)$cashier->display_name->value());
|
||||
return $displayName === '' ? null : $displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resolveWashCertificateDate(): string
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$candidate = $this->completed_at->value() ?: $this->created_at->value();
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function setSafetySealValue(mixed $value): void
|
||||
{
|
||||
self::requireSelected();
|
||||
$normalized = self::normalizeSafetySealValue($value);
|
||||
$this->safety_seal->set($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function completeWashCertificateIfNeeded(string|null $operator = null, $date = null): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
if (!$this->containsWashCertificateItem() || $this->hasWashCertificateAttached()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->generateWashCertificate($this->getSafetySealValue(), $operator, $date);
|
||||
return $this->hasWashCertificateAttached();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and attach a wash certificate directly on an order (without a booking)
|
||||
* @param int|null $safety_seal Optional safety seal number
|
||||
* @param string|null $safety_seal Optional safety seal number
|
||||
* @param string|null $operator Optional operator/employee name who carried out the wash
|
||||
* @param string|DateTime|null $date Optional date of the wash (defaults to current date)
|
||||
* @throws Exception If the order is not selected or required related objects are missing
|
||||
*/
|
||||
public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null, $date = null): void
|
||||
public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null, $date = null): void
|
||||
{
|
||||
self::requireSelected();
|
||||
// Avoid generating duplicate certificates
|
||||
@@ -1404,17 +1826,27 @@ class orders_o extends db
|
||||
if (!$department->exists()) {
|
||||
throw new Exception('Department not found');
|
||||
}
|
||||
// Get branding for the department
|
||||
$branding = (new branding_o())->select((int)$department->branding->value());
|
||||
if (!$branding->exists()) {
|
||||
throw new Exception('Branding not found');
|
||||
// Branding is optional; fall back to the department values when it is not configured.
|
||||
$branding = null;
|
||||
$brandingId = (int)($department->branding->value() ?? 0);
|
||||
if ($brandingId > 0) {
|
||||
$selectedBranding = (new branding_o())->select($brandingId);
|
||||
if ($selectedBranding->exists()) {
|
||||
$branding = $selectedBranding;
|
||||
}
|
||||
}
|
||||
// Get the customer for the order
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||
$department_array = $department->asArray();
|
||||
$department_array['branding'] = $branding->asArray();
|
||||
$customer_array = $customer->asArray();
|
||||
$order_array = $this->asArray();
|
||||
$branding_array = $branding?->asArray() ?? [];
|
||||
$customer_number = (int)$customer->customer_number->value();
|
||||
$customer_name = trim((string)$customer->display_name->value());
|
||||
if ($customer_name === '') {
|
||||
$customer_name = (string)($customer_number > 0 ? $customer_number : $this->customer_id->value());
|
||||
}
|
||||
|
||||
$customer_address = '-';
|
||||
// Format the date as 17:35 02-12-2025
|
||||
$date = ($date instanceof DateTime ? $date : ($date !== null ? new DateTime($date) : new DateTime()));
|
||||
$date_formatted = ($date instanceof DateTime ? $date->format('d-m-Y') : date('d-m-Y'));
|
||||
@@ -1441,17 +1873,17 @@ class orders_o extends db
|
||||
])
|
||||
->addData([
|
||||
'booking_number' => $this->id, // Used as document number on the template
|
||||
'seal_number' => ($safety_seal ?? null),
|
||||
'seal_number' => self::normalizeSafetySealValue($safety_seal),
|
||||
'reg_1' => $order_array['reg_1'],
|
||||
'reg_2' => $order_array['reg_2'],
|
||||
'date' => $date_formatted,
|
||||
'time' => $time_formatted,
|
||||
'carried_out_by' => ($operator ?? null),
|
||||
'department_id' => $department->id,
|
||||
'department_name' => $department_array['branding']['name'] ?: $department_array['name'],
|
||||
'department_address' => $department_array['branding']['address'] ?: $department_array['description'],
|
||||
'customer_name' => $customer->getCustomerName($customer_array['customer_number']),
|
||||
'customer_address' => $customer->getCustomerEcocomicData($customer_array['customer_number'])->economic_customer->address ?: '-',
|
||||
'department_name' => ($branding_array['name'] ?? null) ?: $department_array['name'],
|
||||
'department_address' => ($branding_array['address'] ?? null) ?: $department_array['description'],
|
||||
'customer_name' => $customer_name,
|
||||
'customer_address' => $customer_address,
|
||||
'wash_type' => 'ORDER_WASH'
|
||||
])
|
||||
->getHtml()
|
||||
@@ -1501,6 +1933,44 @@ class orders_o extends db
|
||||
public function isIncludedInInvoicing(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
$override = $this->getIncludeInInvoiceOverride();
|
||||
if ($override !== null) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
return $this->resolveDepartmentIncludedInInvoicing();
|
||||
}
|
||||
|
||||
public static function normalizeNullableBooleanValue(mixed $value): ?bool
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return $value === 1 ? true : ($value === 0 ? false : null);
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string)$value));
|
||||
return match ($normalized) {
|
||||
'1', 'true' => true,
|
||||
'0', 'false' => false,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function getIncludeInInvoiceOverride(): ?bool
|
||||
{
|
||||
self::requireSelected();
|
||||
return self::normalizeNullableBooleanValue($this->include_in_invoice->value());
|
||||
}
|
||||
|
||||
protected function resolveDepartmentIncludedInInvoicing(): bool
|
||||
{
|
||||
return !(new departments_o())->select((int)$this->department_id->value())->isExcludedFromInvoicing();
|
||||
}
|
||||
|
||||
@@ -1551,6 +2021,67 @@ class orders_o extends db
|
||||
return (int)$row['wash_count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $department_ids
|
||||
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
|
||||
if (strtotime($date_start) === false || strtotime($date_end) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
if (strtotime($date_start) > strtotime($date_end)) {
|
||||
throw new Exception('The start date cannot be after the end date');
|
||||
}
|
||||
|
||||
$normalized_department_ids = [];
|
||||
foreach ($department_ids as $department_id) {
|
||||
$normalized_id = (int)$department_id;
|
||||
if ($normalized_id > 0) {
|
||||
$normalized_department_ids[$normalized_id] = true;
|
||||
}
|
||||
}
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids)));
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
|
||||
$sql = "SELECT o.department_id,
|
||||
DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
COUNT(DISTINCT o.id) AS wash_count
|
||||
FROM $this->table o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE o.department_id IN ($department_ids_sql)
|
||||
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND o.deleted_at IS NULL
|
||||
AND p.is_wash = 1
|
||||
GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
|
||||
'wash_count' => (int)($row['wash_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
* @retuns order_items_o[]
|
||||
@@ -1625,4 +2156,44 @@ class orders_o extends db
|
||||
}
|
||||
return $orders;
|
||||
}
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getOrdersWithRegistrationNumberInDateRange(string $registration_number, string $from_date, string $to_date): array
|
||||
{
|
||||
global /** @var db $db */
|
||||
$db;
|
||||
// Validate the date range
|
||||
$from_timestamp = strtotime($from_date);
|
||||
$to_timestamp = strtotime($to_date);
|
||||
if ($from_timestamp === false || $to_timestamp === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
if ($from_timestamp > $to_timestamp) {
|
||||
throw new Exception('The start date cannot be after the end date');
|
||||
}
|
||||
// Prepare the SQL query to find orders with the registration number in the date range
|
||||
$reg = strtoupper(trim($registration_number));
|
||||
if ($reg === '') {
|
||||
return [];
|
||||
}
|
||||
$reg = $db->escape_string($reg);
|
||||
$from_date = $db->escape_string(date('Y-m-d H:i:s', $from_timestamp));
|
||||
$to_date = $db->escape_string(date('Y-m-d H:i:s', $to_timestamp));
|
||||
$sql = "SELECT id FROM $this->table
|
||||
WHERE (UPPER(TRIM(reg_1)) = '$reg' OR UPPER(TRIM(reg_2)) = '$reg' OR UPPER(TRIM(reg_3)) = '$reg')
|
||||
AND created_at BETWEEN '$from_date' AND '$to_date'
|
||||
AND deleted_at IS NULL";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows === 0) {
|
||||
return []; // No orders found with the registration number in the date range
|
||||
}
|
||||
$orders = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$order = new orders_o();
|
||||
$order->select((int)$row['id']);
|
||||
$orders[] = $order;
|
||||
}
|
||||
return $orders;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
class plate_scanners_o extends db
|
||||
@@ -11,12 +12,16 @@ class plate_scanners_o extends db
|
||||
use db_object_t;
|
||||
|
||||
public object_property $department_id;
|
||||
public object_property $lane_id;
|
||||
public object_property $name;
|
||||
public object_property $notes;
|
||||
public object_property $api_key;
|
||||
|
||||
private static bool $schemaInitialized = false;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
self::ensureSchema();
|
||||
$this->setTable('plate_scanners');
|
||||
}
|
||||
|
||||
@@ -41,22 +46,25 @@ class plate_scanners_o extends db
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int');
|
||||
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int');
|
||||
$this->name = new object_property($this->table, $this->id, 'name', 'string');
|
||||
$this->notes = new object_property($this->table, $this->id, 'notes', 'string');
|
||||
$this->api_key = new object_property($this->table, $this->id, 'api_key', 'string');
|
||||
}
|
||||
|
||||
public function add(int $department_id, string $name, string $notes): void
|
||||
public function add(int $department_id, string $name, string $notes, ?int $lane_id = null): void
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
// Generate an API key
|
||||
$api_key = bin2hex(random_bytes(32));
|
||||
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
|
||||
// Avoid SQL injection
|
||||
$name = $db->escape_string($name);
|
||||
$notes = $db->escape_string($notes);
|
||||
$laneValue = $lane_id === null ? 'NULL' : (string)$lane_id;
|
||||
// Create a new record in the database
|
||||
$sql = "INSERT INTO $this->table (department_id, name, notes, api_key) VALUES ($department_id, '$name', '$notes', '$api_key')";
|
||||
$sql = "INSERT INTO $this->table (department_id, lane_id, name, notes, api_key) VALUES ($department_id, $laneValue, '$name', '$notes', '$api_key')";
|
||||
$db->query($sql);
|
||||
|
||||
// Get the id of the new record
|
||||
@@ -69,20 +77,26 @@ class plate_scanners_o extends db
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(int $id, int $department_id, string $name, string $notes): void
|
||||
public function edit(
|
||||
int $id,
|
||||
int $department_id,
|
||||
string $name,
|
||||
string $notes,
|
||||
?int $lane_id = null,
|
||||
bool $laneIdProvided = false
|
||||
): void
|
||||
{
|
||||
global $db, $response;
|
||||
$this->id = $id;
|
||||
global $response;
|
||||
try {
|
||||
// Avoid SQL injection
|
||||
$name = $db->escape_string($name);
|
||||
$notes = $db->escape_string($notes);
|
||||
// Update the record in the database
|
||||
$sql = "UPDATE $this->table SET department_id = $department_id, name = '$name', notes = '$notes' WHERE id = $id";
|
||||
$db->query($sql);
|
||||
|
||||
// Set the values of the object properties
|
||||
$this->getObjectProperties();
|
||||
$this->select($id);
|
||||
// Use object_property setters so cached field values are invalidated before we serialize the scanner.
|
||||
$this->department_id->set($department_id);
|
||||
$this->name->set($name);
|
||||
$this->notes->set($notes);
|
||||
if ($laneIdProvided) {
|
||||
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
|
||||
$this->lane_id->set($lane_id);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$response->error($e->getMessage());
|
||||
}
|
||||
@@ -102,4 +116,116 @@ class plate_scanners_o extends db
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,department_id:int,lane_id:int|null,name:string,notes:string,api_key:string}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'lane_id' => $this->lane_id->value() === null ? null : (int)$this->lane_id->value(),
|
||||
'name' => (string)$this->name->value(),
|
||||
'notes' => (string)$this->notes->value(),
|
||||
'api_key' => (string)$this->api_key->value(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,plate_scanners_o>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getDepartmentScanners(int $departmentId): array
|
||||
{
|
||||
$scanners = [];
|
||||
$rows = self::getFieldsWhere([
|
||||
'department_id' => $departmentId,
|
||||
], ['id']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$scanner = (new plate_scanners_o())->select((int)$row['id']);
|
||||
if ($scanner->exists()) {
|
||||
$scanners[] = $scanner;
|
||||
}
|
||||
}
|
||||
|
||||
return $scanners;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function rotateApiKey(int $id): array
|
||||
{
|
||||
$scanner = $this->select($id);
|
||||
if (!$scanner->exists()) {
|
||||
throw new Exception('Number plate scanner not found');
|
||||
}
|
||||
|
||||
$newApiKey = bin2hex(random_bytes(32));
|
||||
$scanner->api_key->set($newApiKey);
|
||||
|
||||
return $scanner->asArray();
|
||||
}
|
||||
|
||||
private function normalizeLaneId(int $departmentId, ?int $laneId): ?int
|
||||
{
|
||||
if ($laneId === null || $laneId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lane = (new department_lanes_o())->select($laneId);
|
||||
if (!$lane->exists()) {
|
||||
throw new Exception('Department lane not found');
|
||||
}
|
||||
|
||||
if ((int)$lane->department->value() !== $departmentId) {
|
||||
throw new Exception('The lane does not belong to the number plate scanner department');
|
||||
}
|
||||
|
||||
return (int)$lane->id;
|
||||
}
|
||||
|
||||
private static function ensureSchema(): void
|
||||
{
|
||||
if (self::$schemaInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!self::tableHasColumn('plate_scanners', 'lane_id')) {
|
||||
$db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`");
|
||||
}
|
||||
|
||||
self::$schemaInitialized = true;
|
||||
}
|
||||
|
||||
private static function tableHasColumn(string $table, string $column): bool
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = $db->escape_string($table);
|
||||
$column = $db->escape_string($column);
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS c
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND COLUMN_NAME = '$column'"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\products_schema_bootstrap;
|
||||
use traits\db_object_t;
|
||||
|
||||
class products_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||
|
||||
/**
|
||||
* The name of the product
|
||||
* @var object_property
|
||||
@@ -70,6 +74,11 @@ class products_o extends db
|
||||
* @var object_property $order_priority
|
||||
*/
|
||||
public object_property $order_priority;
|
||||
/**
|
||||
* Optional upper quantity limit for a product on one order.
|
||||
* @var object_property $max_quantity_per_order
|
||||
*/
|
||||
public object_property $max_quantity_per_order;
|
||||
/**
|
||||
* The timestamp of when the object was created
|
||||
* @var object_property
|
||||
@@ -83,6 +92,7 @@ class products_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
products_schema_bootstrap::ensureTables();
|
||||
$this->setTable('products');
|
||||
}
|
||||
|
||||
@@ -118,6 +128,7 @@ class products_o extends db
|
||||
$this->is_wash = new object_property($this->table, $this->id, 'is_wash', 'bool', false);
|
||||
$this->display_in_booking_form = new object_property($this->table, $this->id, 'display_in_booking_form', 'bool', false);
|
||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||
$this->max_quantity_per_order = new object_property($this->table, $this->id, 'max_quantity_per_order', 'int', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false);
|
||||
}
|
||||
@@ -206,15 +217,38 @@ class products_o extends db
|
||||
'piktogram' => $this->piktogram->value(),
|
||||
'economic_product_id' => $this->economic_product_id->value(),
|
||||
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
|
||||
'requires_note' => (bool)$this->requires_note->value(),
|
||||
'requires_note' => $this->requiresOrderItemNote(),
|
||||
'is_wash' => (bool)$this->is_wash->value(),
|
||||
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
|
||||
'order_priority' => (int)$this->order_priority->value(),
|
||||
'max_quantity_per_order' => $this->max_quantity_per_order->value() === null ? null : (int)$this->max_quantity_per_order->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function productDataRequiresOrderItemNote(array $product): bool
|
||||
{
|
||||
if ((bool)($product['requires_note'] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
|
||||
}
|
||||
|
||||
public function requiresOrderItemNote(): bool
|
||||
{
|
||||
return self::productDataRequiresOrderItemNote([
|
||||
'id' => $this->id,
|
||||
'name' => (string)$this->name->value(),
|
||||
'requires_note' => (bool)$this->requires_note->value(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply department pricing to a list of products
|
||||
* @param array $products
|
||||
@@ -286,4 +320,4 @@ class products_o extends db
|
||||
self::requireSelected();
|
||||
return $this->id === 41;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_config_versions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $department_id;
|
||||
public object_property $status;
|
||||
public object_property $version_number;
|
||||
public object_property $config_json;
|
||||
public object_property $validation_result_json;
|
||||
public object_property $source_version_id;
|
||||
public object_property $created_by;
|
||||
public object_property $published_at;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('selfserve_config_versions');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->version_number = new object_property($this->table, $this->id, 'version_number', 'int', false);
|
||||
$this->config_json = new object_property($this->table, $this->id, 'config_json', 'json', false);
|
||||
$this->validation_result_json = new object_property($this->table, $this->id, 'validation_result_json', 'json', false);
|
||||
$this->source_version_id = new object_property($this->table, $this->id, 'source_version_id', 'int', false);
|
||||
$this->created_by = new object_property($this->table, $this->id, 'created_by', 'int', false);
|
||||
$this->published_at = new object_property($this->table, $this->id, 'published_at', 'datetime', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
// No-op for now.
|
||||
}
|
||||
|
||||
public function add(
|
||||
int $departmentId,
|
||||
string $status,
|
||||
int $versionNumber,
|
||||
array $config,
|
||||
?array $validationResult = null,
|
||||
?int $sourceVersionId = null,
|
||||
?int $createdBy = null,
|
||||
?string $publishedAt = null,
|
||||
): self {
|
||||
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
|
||||
if ($configJson === false) {
|
||||
throw new \RuntimeException('Failed to encode self-serve config JSON: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
$validationJson = null;
|
||||
if ($validationResult !== null) {
|
||||
$validationJson = json_encode($validationResult, JSON_UNESCAPED_UNICODE);
|
||||
if ($validationJson === false) {
|
||||
throw new \RuntimeException('Failed to encode self-serve validation JSON: ' . json_last_error_msg());
|
||||
}
|
||||
}
|
||||
|
||||
$this->id = $this->add_object([
|
||||
'department_id' => $departmentId,
|
||||
'status' => $status,
|
||||
'version_number' => $versionNumber,
|
||||
// Pass JSON as escaped strings to avoid SQL quoting issues inside generic add_object().
|
||||
'config_json' => $configJson,
|
||||
'validation_result_json' => $validationJson,
|
||||
'source_version_id' => $sourceVersionId,
|
||||
'created_by' => $createdBy,
|
||||
'published_at' => $publishedAt,
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function selectLatestByDepartmentAndStatus(int $departmentId, string $status): self
|
||||
{
|
||||
$rows = $this->getFieldsWhere([
|
||||
'department_id' => $departmentId,
|
||||
'status' => $status,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
if ($rows === []) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
|
||||
$this->select((int)$rows[0]['id']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function listByDepartment(int $departmentId): array
|
||||
{
|
||||
$rows = $this->getFieldsWhere([
|
||||
'department_id' => $departmentId,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
|
||||
return array_map(function (array $row): array {
|
||||
return (new selfserve_config_versions_o())->select((int)$row['id'])->asArray();
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'version_number' => (int)$this->version_number->value(),
|
||||
'config' => (array)($this->config_json->value() ?? []),
|
||||
'validation_result' => (array)($this->validation_result_json->value() ?? []),
|
||||
'source_version_id' => $this->source_version_id->value() === null ? null : (int)$this->source_version_id->value(),
|
||||
'created_by' => $this->created_by->value() === null ? null : (int)$this->created_by->value(),
|
||||
'published_at' => $this->published_at->value() === null ? null : (string)$this->published_at->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_machine_types_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $name;
|
||||
public object_property $description;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('selfserve_machine_types');
|
||||
}
|
||||
|
||||
public function add(string $name, ?string $description = null): self
|
||||
{
|
||||
global $db;
|
||||
$name = $db->escape_string($name);
|
||||
$description = $description === null ? null : $db->escape_string($description);
|
||||
|
||||
$this->id = self::add_object([
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->name = new object_property($this->table, $this->id, 'name', 'string', false);
|
||||
$this->description = new object_property($this->table, $this->id, 'description', 'string', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
// No dedicated cache invalidation yet.
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'name' => (string)$this->name->value(),
|
||||
'description' => $this->description->value() === null ? null : (string)$this->description->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_session_answers_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $session_id;
|
||||
public object_property $question_id;
|
||||
public object_property $question_text;
|
||||
public object_property $answer_value;
|
||||
public object_property $answered_at;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('selfserve_wash_session_answers');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
|
||||
$this->question_id = new object_property($this->table, $this->id, 'question_id', 'int', false);
|
||||
$this->question_text = new object_property($this->table, $this->id, 'question_text', 'string', false);
|
||||
$this->answer_value = new object_property($this->table, $this->id, 'answer_value', 'bool', false);
|
||||
$this->answered_at = new object_property($this->table, $this->id, 'answered_at', 'datetime', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
// No dedicated cache invalidation yet.
|
||||
}
|
||||
|
||||
public function upsert(int $sessionId, int $questionId, string $questionText, bool $answerValue): self
|
||||
{
|
||||
$rows = $this->getFieldsWhere([
|
||||
'session_id' => $sessionId,
|
||||
'question_id' => $questionId,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->select((int)$rows[0]['id']);
|
||||
$this->question_text->set($questionText);
|
||||
$this->answer_value->set($answerValue);
|
||||
$this->answered_at->set(date('Y-m-d H:i:s'));
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->id = self::add_object([
|
||||
'session_id' => $sessionId,
|
||||
'question_id' => $questionId,
|
||||
'question_text' => $questionText,
|
||||
'answer_value' => $answerValue,
|
||||
'answered_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function listBySession(int $sessionId): array
|
||||
{
|
||||
return $this->getFieldsWhere([
|
||||
'session_id' => $sessionId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'question_id', 'question_text', 'answer_value', 'answered_at']);
|
||||
}
|
||||
|
||||
public function deleteMissingForSession(int $sessionId, array $questionIds): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$sessionId = (int)$sessionId;
|
||||
$questionIds = array_values(array_unique(array_map(static fn(mixed $value): int => (int)$value, $questionIds)));
|
||||
|
||||
if ($questionIds === []) {
|
||||
$db->query("DELETE FROM $this->table WHERE session_id = $sessionId");
|
||||
return;
|
||||
}
|
||||
|
||||
$questionIdsSql = implode(',', $questionIds);
|
||||
$db->query("DELETE FROM $this->table WHERE session_id = $sessionId AND question_id NOT IN ($questionIdsSql)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use modules\selfserve\helpers\selfserve_wash_event_type;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_session_events_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $session_id;
|
||||
public object_property $event_type;
|
||||
public object_property $payload_json;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('selfserve_wash_session_events');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
|
||||
$this->event_type = new object_property($this->table, $this->id, 'event_type', 'string', false);
|
||||
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
// No dedicated cache invalidation yet.
|
||||
}
|
||||
|
||||
public function add(int $sessionId, selfserve_wash_event_type $eventType, ?array $payload = null): self
|
||||
{
|
||||
$this->id = self::add_object([
|
||||
'session_id' => $sessionId,
|
||||
'event_type' => $eventType->value,
|
||||
'payload_json' => $payload,
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function listBySession(int $sessionId): array
|
||||
{
|
||||
return $this->getFieldsWhere([
|
||||
'session_id' => $sessionId,
|
||||
], ['id', 'event_type', 'payload_json', 'created_at']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_session_tasks_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $session_id;
|
||||
public object_property $task_id;
|
||||
public object_property $task_text;
|
||||
public object_property $description;
|
||||
public object_property $services;
|
||||
public object_property $buttons;
|
||||
public object_property $dynamic_images_vehicle_type;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('selfserve_wash_session_tasks');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
|
||||
$this->task_id = new object_property($this->table, $this->id, 'task_id', 'int', false);
|
||||
$this->task_text = new object_property($this->table, $this->id, 'task_text', 'string', false);
|
||||
$this->description = new object_property($this->table, $this->id, 'description', 'string', false);
|
||||
$this->services = new object_property($this->table, $this->id, 'services', 'json', false);
|
||||
$this->buttons = new object_property($this->table, $this->id, 'buttons', 'json', false);
|
||||
$this->dynamic_images_vehicle_type = new object_property($this->table, $this->id, 'dynamic_images_vehicle_type', 'string', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
// No dedicated cache invalidation yet.
|
||||
}
|
||||
|
||||
public function addSnapshot(
|
||||
int $sessionId,
|
||||
?int $taskId,
|
||||
string $taskText,
|
||||
?string $description = null,
|
||||
?array $services = null,
|
||||
?array $buttons = null,
|
||||
?int $thumb_position = null,
|
||||
): self {
|
||||
$this->id = self::add_object([
|
||||
'session_id' => $sessionId,
|
||||
'task_id' => $taskId,
|
||||
'task_text' => $taskText,
|
||||
'description' => $description,
|
||||
'services' => $services,
|
||||
'buttons' => $buttons,
|
||||
'dynamic_images_vehicle_type' => $thumb_position === null ? null : (int)$thumb_position, // The rotations to do on the image.
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function deleteBySession(int $sessionId): void
|
||||
{
|
||||
global $db;
|
||||
$db->query("DELETE FROM $this->table WHERE session_id = " . (int)$sessionId);
|
||||
}
|
||||
|
||||
public function listBySession(int $sessionId): array
|
||||
{
|
||||
return $this->getFieldsWhere([
|
||||
'session_id' => $sessionId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'task_id', 'task_text', 'description', 'services', 'buttons', 'dynamic_images_vehicle_type']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use DateTime;
|
||||
use modules\selfserve\helpers\selfserve_wash_session_status;
|
||||
use traits\db_object_t;
|
||||
|
||||
class selfserve_wash_sessions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const TERMINAL_STATUSES = [
|
||||
'COMPLETED',
|
||||
'FORCE_STOPPED',
|
||||
];
|
||||
|
||||
public object_property $lane_id;
|
||||
public object_property $department_id;
|
||||
public object_property $machine_type_id;
|
||||
public object_property $customer_number;
|
||||
public object_property $vehicle_id;
|
||||
public object_property $vehicle_type_id;
|
||||
public object_property $reg;
|
||||
public object_property $status;
|
||||
public object_property $allowed;
|
||||
public object_property $machine_relay_enabled;
|
||||
public object_property $machine_relay_enabled_at;
|
||||
public object_property $machine_start_triggered;
|
||||
public object_property $machine_start_triggered_at;
|
||||
public object_property $wash_started_at;
|
||||
public object_property $order_id;
|
||||
public object_property $completed_at;
|
||||
public object_property $metadata_json;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
$this->setTable('selfserve_wash_sessions');
|
||||
}
|
||||
|
||||
public function add(
|
||||
int $laneId,
|
||||
int $departmentId,
|
||||
?int $machineTypeId,
|
||||
?int $customerNumber,
|
||||
string $reg,
|
||||
?int $vehicleId,
|
||||
?int $vehicleTypeId,
|
||||
selfserve_wash_session_status $status,
|
||||
bool $allowed = false,
|
||||
?array $metadata = null
|
||||
): self {
|
||||
$this->id = self::add_object([
|
||||
'lane_id' => $laneId,
|
||||
'department_id' => $departmentId,
|
||||
'machine_type_id' => $machineTypeId,
|
||||
'customer_number' => $customerNumber,
|
||||
'vehicle_id' => $vehicleId,
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'reg' => selfserve::standardize_registration($reg),
|
||||
'status' => $status->value,
|
||||
'allowed' => $allowed,
|
||||
'metadata_json' => $metadata,
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
|
||||
$this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false);
|
||||
$this->vehicle_id = new object_property($this->table, $this->id, 'vehicle_id', 'int', false);
|
||||
$this->vehicle_type_id = new object_property($this->table, $this->id, 'vehicle_type_id', 'int', false);
|
||||
$this->reg = new object_property($this->table, $this->id, 'reg', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->allowed = new object_property($this->table, $this->id, 'allowed', 'bool', false);
|
||||
$this->machine_relay_enabled = new object_property($this->table, $this->id, 'machine_relay_enabled', 'bool', false);
|
||||
$this->machine_relay_enabled_at = new object_property($this->table, $this->id, 'machine_relay_enabled_at', 'datetime', false);
|
||||
$this->machine_start_triggered = new object_property($this->table, $this->id, 'machine_start_triggered', 'bool', false);
|
||||
$this->machine_start_triggered_at = new object_property($this->table, $this->id, 'machine_start_triggered_at', 'datetime', false);
|
||||
$this->wash_started_at = new object_property($this->table, $this->id, 'wash_started_at', 'datetime', false);
|
||||
$this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', false);
|
||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'datetime', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
// No dedicated cache invalidation yet.
|
||||
}
|
||||
|
||||
public function updateStatus(selfserve_wash_session_status $status): void
|
||||
{
|
||||
$this->status->set($status->value);
|
||||
}
|
||||
|
||||
public static function isTerminalStatus(?string $status): bool
|
||||
{
|
||||
return in_array(strtoupper(trim((string)$status)), self::TERMINAL_STATUSES, true);
|
||||
}
|
||||
|
||||
public static function terminalStatusSqlList(): string
|
||||
{
|
||||
return "'" . implode("','", array_map(
|
||||
static fn(string $status): string => str_replace("'", "''", $status),
|
||||
self::TERMINAL_STATUSES
|
||||
)) . "'";
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->completed_at->value() === null
|
||||
&& !self::isTerminalStatus((string)$this->status->value());
|
||||
}
|
||||
|
||||
public function markRelayEnabled(): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->machine_relay_enabled->set(true);
|
||||
$this->machine_relay_enabled_at->set($now);
|
||||
$this->status->set(selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value);
|
||||
}
|
||||
|
||||
public function markRelayDisabled(): void
|
||||
{
|
||||
$this->machine_relay_enabled->set(false);
|
||||
$this->machine_relay_enabled_at->set(null);
|
||||
}
|
||||
|
||||
public function markMachineStartTriggered(?string $washStartedAt = null): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$resolvedWashStartedAt = $washStartedAt ?? $now;
|
||||
$this->machine_start_triggered->set(true);
|
||||
$this->machine_start_triggered_at->set($now);
|
||||
$this->wash_started_at->set($resolvedWashStartedAt);
|
||||
if ((bool)$this->machine_relay_enabled->value() !== true) {
|
||||
$this->machine_relay_enabled->set(true);
|
||||
$this->machine_relay_enabled_at->set($now);
|
||||
}
|
||||
$this->status->set(selfserve_wash_session_status::MACHINE_STARTED->value);
|
||||
}
|
||||
|
||||
public function markCompleted(?int $orderId = null): void
|
||||
{
|
||||
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||
if ($orderId !== null) {
|
||||
$this->order_id->set($orderId);
|
||||
}
|
||||
$this->status->set(selfserve_wash_session_status::COMPLETED->value);
|
||||
}
|
||||
|
||||
public function markForceStopped(?int $orderId = null, ?array $metadata = null): void
|
||||
{
|
||||
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||
if ($orderId !== null) {
|
||||
$this->order_id->set($orderId);
|
||||
}
|
||||
if ($metadata !== null) {
|
||||
$existing = $this->metadata_json->value();
|
||||
$existing = is_array($existing) ? $existing : [];
|
||||
$existing['force_stop'] = $metadata;
|
||||
$this->metadata_json->set($existing);
|
||||
}
|
||||
$this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value);
|
||||
}
|
||||
|
||||
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
|
||||
{
|
||||
$filters = [
|
||||
'lane_id' => $laneId,
|
||||
'completed_at' => null,
|
||||
'deleted_at' => null,
|
||||
];
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null)
|
||||
));
|
||||
if ($rows === []) {
|
||||
return $this;
|
||||
}
|
||||
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
|
||||
$this->select((int)$rows[0]['id']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self
|
||||
{
|
||||
$filters = [
|
||||
'lane_id' => $laneId,
|
||||
'reg' => selfserve::standardize_registration($reg),
|
||||
'completed_at' => null,
|
||||
'deleted_at' => null,
|
||||
];
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null)
|
||||
));
|
||||
if ($rows === []) {
|
||||
return $this;
|
||||
}
|
||||
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
|
||||
$this->select((int)$rows[0]['id']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function selectLatestByLaneAndReg(int $laneId, string $reg): self
|
||||
{
|
||||
$rows = $this->getFieldsWhere([
|
||||
'lane_id' => $laneId,
|
||||
'reg' => selfserve::standardize_registration($reg),
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
if ($rows === []) {
|
||||
return $this;
|
||||
}
|
||||
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
|
||||
$this->select((int)$rows[0]['id']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'lane_id' => (int)$this->lane_id->value(),
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'machine_type_id' => $this->machine_type_id->value() === null ? null : (int)$this->machine_type_id->value(),
|
||||
'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(),
|
||||
'vehicle_id' => $this->vehicle_id->value() === null ? null : (int)$this->vehicle_id->value(),
|
||||
'vehicle_type_id' => $this->vehicle_type_id->value() === null ? null : (int)$this->vehicle_type_id->value(),
|
||||
'reg' => (string)$this->reg->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'allowed' => (bool)$this->allowed->value(),
|
||||
'machine_relay_enabled' => (bool)$this->machine_relay_enabled->value(),
|
||||
'machine_relay_enabled_at' => $this->machine_relay_enabled_at->value() === null ? null : (string)$this->machine_relay_enabled_at->value(),
|
||||
'machine_start_triggered' => (bool)$this->machine_start_triggered->value(),
|
||||
'machine_start_triggered_at' => $this->machine_start_triggered_at->value() === null ? null : (string)$this->machine_start_triggered_at->value(),
|
||||
'wash_started_at' => $this->wash_started_at->value() === null ? null : (string)$this->wash_started_at->value(),
|
||||
'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(),
|
||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'open' => $this->isOpen(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getElapsedMinutes(): int
|
||||
{
|
||||
$startAt = $this->wash_started_at->value() ?? $this->machine_start_triggered_at->value();
|
||||
if ($startAt === null || trim((string)$startAt) === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$start = new DateTime((string)$startAt);
|
||||
$endAt = $this->completed_at->value() ?? date('Y-m-d H:i:s');
|
||||
$end = new DateTime((string)$endAt);
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($start > $end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$diff = $start->diff($end);
|
||||
return (int)(($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i);
|
||||
}
|
||||
}
|
||||
@@ -93,4 +93,19 @@ class stripe_module_orders_o extends db
|
||||
return (new stripe())->invoice->retrieve($this->invoice_id->value());
|
||||
}
|
||||
|
||||
}
|
||||
public function clear(): void
|
||||
{
|
||||
if (!$this->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
public function deleteForOrder(int $order_id): void
|
||||
{
|
||||
$record = (new self())->select($order_id);
|
||||
$record->clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,25 +26,27 @@ class stripe_payment_intents_o extends db
|
||||
|
||||
/**
|
||||
* Add a new payment intent
|
||||
* @param int $order_id The id of the order
|
||||
* @param string $payment_intent_id The id of the payment intent
|
||||
* @param string $client_secret The client secret of the payment intent
|
||||
* @param mixed $data The data to be added
|
||||
* @param int|null $tax_percentage The tax percentage to be added
|
||||
* @return void
|
||||
* @throws Exception If the object was not created successfully
|
||||
* @throws Exception
|
||||
*/
|
||||
public function add(int $order_id, string $payment_intent_id, string $client_secret, mixed $data = null, int $tax_percentage = null): void
|
||||
{
|
||||
// Convert the data to a JSON string (If it's an object or array)
|
||||
public function add(
|
||||
int $order_id,
|
||||
string $payment_intent_id,
|
||||
string $client_secret,
|
||||
mixed $data = null,
|
||||
?string $reader_id = null,
|
||||
int $tax_percentage = null
|
||||
): void {
|
||||
if (is_object($data) || is_array($data)) {
|
||||
$data = json_encode($data);
|
||||
}
|
||||
|
||||
$this->clearOrderPaymentIntents($order_id);
|
||||
$tmp_id = self::add_object([
|
||||
'order_id' => $order_id,
|
||||
'payment_intent_id' => $payment_intent_id,
|
||||
'client_secret' => $client_secret,
|
||||
'data' => $data,
|
||||
'reader_id' => $reader_id,
|
||||
'tax_percentage' => ($tax_percentage !== null) ? (int)$tax_percentage : 0,
|
||||
]);
|
||||
$this->id = $tmp_id;
|
||||
@@ -58,53 +60,128 @@ class stripe_payment_intents_o extends db
|
||||
$this->payment_intent_id = new object_property($this->table, $this->id, 'payment_intent_id', 'string', false);
|
||||
$this->client_secret = new object_property($this->table, $this->id, 'client_secret', 'string', false);
|
||||
$this->data = new object_property($this->table, $this->id, 'data', 'string', false);
|
||||
$this->reader_id = new object_property($this->table, $this->id, 'reader_id', 'int', false);
|
||||
$this->reader_id = new object_property($this->table, $this->id, 'reader_id', 'string', false);
|
||||
$this->tax_percentage = new object_property($this->table, $this->id, 'tax_percentage', 'int', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
// TODO: Add cache invalidation
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an order has a payment intent
|
||||
* @param int $order_id The id of the order
|
||||
* @return bool True if the order has a payment intent, false otherwise
|
||||
*/
|
||||
public function doesOrderHavePaymentIntent(int $order_id): bool
|
||||
{
|
||||
return self::countRowsWhere([
|
||||
'order_id' => $order_id,
|
||||
]) > 0;
|
||||
return count($this->getOrderPaymentIntentRows($order_id)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the payment intent id of an order
|
||||
* @param int $order_id The id of the order
|
||||
* @return self
|
||||
* @throws Exception If the object was not selected
|
||||
* @throws Exception
|
||||
*/
|
||||
public function selectOrderPaymentIntent(int $order_id): self
|
||||
{
|
||||
$tmp = self::getFieldsWhere([
|
||||
'order_id' => $order_id,
|
||||
],
|
||||
['id']);
|
||||
if (count($tmp) > 0) {
|
||||
$this->id = $tmp[0]['id'];
|
||||
self::getObjectProperties();
|
||||
return $this;
|
||||
} else {
|
||||
$rows = $this->getOrderPaymentIntentRows($order_id);
|
||||
if (count($rows) === 0) {
|
||||
throw new Exception('No payment intent found for order id: ' . $order_id);
|
||||
}
|
||||
|
||||
$this->id = (int)$rows[0]['id'];
|
||||
self::getObjectProperties();
|
||||
$this->deleteDuplicateOrderPaymentIntents($order_id, $this->id);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* There's no need to keep track of when the payment intent was created
|
||||
* so we just forcefully delete it.
|
||||
* @throws Exception If the object was not selected
|
||||
* @throws Exception If the object was not deleted successfully
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getOrderPaymentIntentRows(int $order_id): array
|
||||
{
|
||||
$rows = self::getFieldsWhere(
|
||||
[
|
||||
'order_id' => $order_id,
|
||||
],
|
||||
['id', 'payment_intent_id', 'reader_id', 'tax_percentage']
|
||||
);
|
||||
|
||||
usort($rows, static fn(array $a, array $b): int => ((int)($b['id'] ?? 0)) <=> ((int)($a['id'] ?? 0)));
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function clearOrderPaymentIntents(int $order_id, ?int $keepId = null): void
|
||||
{
|
||||
foreach ($this->getOrderPaymentIntentRows($order_id) as $row) {
|
||||
$rowId = (int)($row['id'] ?? 0);
|
||||
if ($rowId <= 0 || ($keepId !== null && $rowId === $keepId)) {
|
||||
continue;
|
||||
}
|
||||
self::delete_object($this->getTable(), $rowId);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateStoredPaymentIntent(mixed $paymentIntent): void
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
if (is_object($paymentIntent) && method_exists($paymentIntent, 'toJSON')) {
|
||||
$encodedData = $paymentIntent->toJSON();
|
||||
} elseif (is_array($paymentIntent) || is_object($paymentIntent)) {
|
||||
$encodedData = json_encode($paymentIntent);
|
||||
} else {
|
||||
$encodedData = (string)$paymentIntent;
|
||||
}
|
||||
|
||||
$this->data->set($encodedData);
|
||||
|
||||
$normalizedClientSecret = null;
|
||||
$normalizedReaderId = null;
|
||||
$normalizedTaxPercentage = null;
|
||||
|
||||
if (is_object($paymentIntent) || is_array($paymentIntent)) {
|
||||
$metadata = is_array($paymentIntent)
|
||||
? ($paymentIntent['metadata'] ?? [])
|
||||
: ($paymentIntent->metadata ?? null);
|
||||
|
||||
$normalizedClientSecret = is_array($paymentIntent)
|
||||
? ($paymentIntent['client_secret'] ?? null)
|
||||
: ($paymentIntent->client_secret ?? null);
|
||||
|
||||
if (is_array($metadata)) {
|
||||
$normalizedReaderId = $metadata['reader_id'] ?? $metadata['reader'] ?? null;
|
||||
$normalizedTaxPercentage = $metadata['tax_percentage'] ?? null;
|
||||
} elseif (is_object($metadata)) {
|
||||
$normalizedReaderId = $metadata->reader_id ?? $metadata->reader ?? null;
|
||||
$normalizedTaxPercentage = $metadata->tax_percentage ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($normalizedClientSecret) && $normalizedClientSecret !== '') {
|
||||
$this->client_secret->set($normalizedClientSecret);
|
||||
}
|
||||
if ($normalizedReaderId !== null) {
|
||||
$this->reader_id->set((string)$normalizedReaderId);
|
||||
}
|
||||
if ($normalizedTaxPercentage !== null && is_numeric($normalizedTaxPercentage)) {
|
||||
$this->tax_percentage->set((int)$normalizedTaxPercentage);
|
||||
}
|
||||
|
||||
self::objectChanged();
|
||||
}
|
||||
|
||||
public function setReaderId(?string $readerId): void
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
if ($readerId === null || trim($readerId) === '') {
|
||||
$this->reader_id->nullify();
|
||||
self::objectChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reader_id->set(trim($readerId));
|
||||
self::objectChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete(): void
|
||||
{
|
||||
@@ -114,19 +191,52 @@ class stripe_payment_intents_o extends db
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the payment intent on the reader
|
||||
* @throws Exception If the object was not selected
|
||||
* @throws Exception If the object was not deleted successfully
|
||||
* @throws Exception
|
||||
*/
|
||||
public function cancelPaymentIntent(): void
|
||||
{
|
||||
self::requireSelected();
|
||||
// If a reader id is set, cancel the payment intent on the reader
|
||||
if (!empty($this->reader_id->value())) {
|
||||
$stripe = new stripe();
|
||||
$stripe->readers->sendCancelPaymentIntent($this->reader_id->value(), $this->payment_intent_id->value());
|
||||
$stripe = new stripe();
|
||||
|
||||
$readerId = trim((string)($this->reader_id->value() ?? ''));
|
||||
if ($readerId !== '') {
|
||||
try {
|
||||
$stripe->readers->sendCancelPaymentIntent($readerId);
|
||||
} catch (\Stripe\Exception\InvalidRequestException) {
|
||||
// The reader may already be idle or missing. Clearing local state is sufficient.
|
||||
}
|
||||
$this->reader_id->nullify();
|
||||
}
|
||||
|
||||
$paymentIntentId = trim((string)($this->payment_intent_id->value() ?? ''));
|
||||
if ($paymentIntentId === '') {
|
||||
self::objectChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$paymentIntent = $stripe->payment_intents->get($paymentIntentId);
|
||||
} catch (\Stripe\Exception\InvalidRequestException) {
|
||||
self::objectChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
$status = strtolower((string)($paymentIntent->status ?? ''));
|
||||
if (in_array($status, ['succeeded', 'canceled'], true)) {
|
||||
$this->updateStoredPaymentIntent($paymentIntent);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$cancelledPaymentIntent = $stripe->payment_intents->cancel($paymentIntentId);
|
||||
$this->updateStoredPaymentIntent($cancelledPaymentIntent);
|
||||
} catch (\Stripe\Exception\InvalidRequestException) {
|
||||
self::objectChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteDuplicateOrderPaymentIntents(int $order_id, int $keepId): void
|
||||
{
|
||||
$this->clearOrderPaymentIntents($order_id, $keepId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,63 @@ class subuser_grants_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
const defaultPermissions = [
|
||||
subusers_permission_node_key::VEHICLES_LIST,
|
||||
subusers_permission_node_key::SELFSERVE_ADD,
|
||||
subusers_permission_node_key::BOOKINGS_LIST,
|
||||
subusers_permission_node_key::BOOKINGS_ADD,
|
||||
subusers_permission_node_key::BOOKINGS_EDIT,
|
||||
subusers_permission_node_key::BOOKINGS_DELETE,
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
];
|
||||
|
||||
public static function normalizePermissionsValue(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($raw instanceof subusers_permission_node_key) {
|
||||
return [$raw->name];
|
||||
}
|
||||
|
||||
if (is_array($raw)) {
|
||||
$permissions = [];
|
||||
$permissionCandidates = array_is_list($raw)
|
||||
? $raw
|
||||
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
|
||||
|
||||
foreach ($permissionCandidates as $permission) {
|
||||
if ($permission instanceof subusers_permission_node_key) {
|
||||
$permission = $permission->name;
|
||||
}
|
||||
|
||||
if (!is_string($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permission = strtoupper(trim($permission));
|
||||
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
$permissions[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return self::normalizePermissionsValue($decoded);
|
||||
}
|
||||
|
||||
$permission = strtoupper(trim($raw));
|
||||
if (subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
return [$permission];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
@@ -62,23 +111,7 @@ class subuser_grants_o extends db
|
||||
'subuser' => (int)$this->subuser->value(),
|
||||
'enabled' => (bool)$this->enabled->value(),
|
||||
'note' => $this->note->value(),
|
||||
'permissions' => (function ($raw) {
|
||||
// Handle different representations from object_property:
|
||||
// - When type is 'json', object_property::value() may already return an array
|
||||
// - In older behavior, it could return a JSON string
|
||||
// Normalize to an array for API output
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return [];
|
||||
})($this->permissions->value()),
|
||||
'permissions' => self::normalizePermissionsValue($this->permissions->value()),
|
||||
'created_at' => $this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value(),
|
||||
'deleted_at' => $this->deleted_at->value(),
|
||||
@@ -99,12 +132,13 @@ class subuser_grants_o extends db
|
||||
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
||||
{
|
||||
global $db;
|
||||
$permissions = self::normalizePermissionsValue($permissions);
|
||||
$tmp = $this->add_object([
|
||||
'billing_customer_number' => (int)$billing_customer_number,
|
||||
'subuser' => (int)$subuser,
|
||||
'enabled' => (bool)$enabled,
|
||||
'note' => !empty($note) ? $db->escape_string($note) : null,
|
||||
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
|
||||
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
$this->id = (int)$tmp;
|
||||
$this->getObjectProperties();
|
||||
@@ -122,11 +156,33 @@ class subuser_grants_o extends db
|
||||
// Extract permissions from the grants
|
||||
$permissions = [];
|
||||
foreach ($grants as $grant) {
|
||||
$grant_permissions = json_decode($grant['permissions'], true);
|
||||
$grant_permissions = self::normalizePermissionsValue($grant['permissions'] ?? null);
|
||||
if (is_array($grant_permissions)) {
|
||||
$permissions = array_merge($permissions, $grant_permissions);
|
||||
}
|
||||
}
|
||||
return $permissions;
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
}
|
||||
|
||||
public function getGrantForSubuserAndCustomer(int $subuser_id, int $customer_number, bool $includeDisabled = true): ?subuser_grants_o
|
||||
{
|
||||
$grants = self::getFieldsWhere([
|
||||
'billing_customer_number' => $customer_number,
|
||||
'subuser' => $subuser_id,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'enabled']);
|
||||
|
||||
if (!$includeDisabled) {
|
||||
$grants = array_values(array_filter($grants, static fn (array $grant): bool => (int)($grant['enabled'] ?? 0) === 1));
|
||||
}
|
||||
|
||||
if (count($grants) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($grants, static fn (array $left, array $right): int => (int)$right['id'] <=> (int)$left['id']);
|
||||
$grant = (new subuser_grants_o())->select((int)$grants[0]['id']);
|
||||
$grant->getObjectProperties();
|
||||
return $grant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ class subusers_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const PASSWORD_MIN_LENGTH = 8;
|
||||
public const PASSWORD_MAX_LENGTH = 255;
|
||||
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
|
||||
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
|
||||
|
||||
public object_property $username;
|
||||
public object_property $password;
|
||||
public object_property $name;
|
||||
@@ -62,6 +67,23 @@ class subusers_o extends db
|
||||
return (bool)$this->two_factor_enabled->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function assertValidPassword(string $password): void
|
||||
{
|
||||
if (
|
||||
strlen($password) < self::PASSWORD_MIN_LENGTH
|
||||
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|
||||
|| !preg_match(self::PASSWORD_PATTERN, $password)
|
||||
) {
|
||||
throw new Exception(
|
||||
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
|
||||
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -103,11 +125,9 @@ class subusers_o extends db
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
if (!empty($password)) {
|
||||
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
|
||||
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
|
||||
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
|
||||
}
|
||||
$passwordWasProvided = !empty($password);
|
||||
if ($passwordWasProvided) {
|
||||
self::assertValidPassword($password);
|
||||
// Hash the password
|
||||
$password = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
@@ -163,6 +183,7 @@ class subusers_o extends db
|
||||
public function setPassword(string $password): self
|
||||
{
|
||||
self::requireSelected();
|
||||
self::assertValidPassword($password);
|
||||
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
||||
return $this;
|
||||
}
|
||||
@@ -261,6 +282,34 @@ class subusers_o extends db
|
||||
return $subuser;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getSubuserByEmail(string $email): ?subusers_o
|
||||
{
|
||||
global $db;
|
||||
$email = $db->escape_string($email);
|
||||
$tmp = self::getFieldsWhere([
|
||||
'email' => $email,
|
||||
], ['id']);
|
||||
if (count($tmp) === 0) {
|
||||
return null;
|
||||
}
|
||||
$subuser = (new subusers_o())->select((int)$tmp[0]['id']);
|
||||
$subuser->getObjectProperties();
|
||||
return $subuser;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function requiresSetup(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
$password = $this->password->value();
|
||||
return !is_string($password) || trim($password) === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RandomException
|
||||
* @throws Exception
|
||||
@@ -277,6 +326,11 @@ class subusers_o extends db
|
||||
return $session_token;
|
||||
}
|
||||
|
||||
public function invalidateSessionToken(string $token): void
|
||||
{
|
||||
$this->deleteCached('session_token:' . $token, 'subuser_sessions');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $token The session token
|
||||
* @return subusers_o|null The subuser object or null if the token is invalid or expired
|
||||
@@ -332,4 +386,4 @@ class subusers_o extends db
|
||||
$grant = new subuser_user_grant((int)$this->id, (int)$customer_number);
|
||||
return $grant->hasNode($permission_node_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class user_key_value_pairs_o extends db
|
||||
|
||||
public function setUser($user_id): user_key_value_pairs_o
|
||||
{
|
||||
$this->user_id = $user_id;
|
||||
$this->user_id = (int)$user_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class user_key_value_pairs_o extends db
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$userId = (int)$this->user_id;
|
||||
// Avoid SQL injection
|
||||
$var = $db->escape_string($var);
|
||||
$val = $db->escape_string($val);
|
||||
@@ -48,9 +49,9 @@ class user_key_value_pairs_o extends db
|
||||
$exists = $this->getValue($var);
|
||||
// Create a new record in the database if it doesn't exist
|
||||
if ($exists !== null) {
|
||||
$sql = "UPDATE $this->table SET val = '$val' WHERE user_id = $this->user_id AND var = '$var'";
|
||||
$sql = "UPDATE $this->table SET val = '$val' WHERE user_id = $userId AND var = '$var'";
|
||||
} else {
|
||||
$sql = "INSERT INTO $this->table (user_id, var, val) VALUES ($this->user_id, '$var', '$val')";
|
||||
$sql = "INSERT INTO $this->table (user_id, var, val) VALUES ($userId, '$var', '$val')";
|
||||
}
|
||||
$db->query($sql);
|
||||
return $this;
|
||||
@@ -70,10 +71,11 @@ class user_key_value_pairs_o extends db
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$userId = (int)$this->user_id;
|
||||
// Avoid SQL injection
|
||||
$var = $db->escape_string($var);
|
||||
// Get the record from the database
|
||||
$sql = "SELECT val FROM $this->table WHERE user_id = $this->user_id AND var = '$var'";
|
||||
$sql = "SELECT val FROM $this->table WHERE user_id = $userId AND var = '$var'";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows > 0) {
|
||||
return $db->fetch_assoc($result)['val'];
|
||||
@@ -85,10 +87,11 @@ class user_key_value_pairs_o extends db
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$userId = (int)$this->user_id;
|
||||
// Avoid SQL injection
|
||||
$var = $db->escape_string($var);
|
||||
// Create a new record in the database
|
||||
$sql = "DELETE FROM $this->table WHERE user_id = $this->user_id AND var = '$var'";
|
||||
$sql = "DELETE FROM $this->table WHERE user_id = $userId AND var = '$var'";
|
||||
$db->query($sql);
|
||||
return $this;
|
||||
}
|
||||
@@ -97,8 +100,9 @@ class user_key_value_pairs_o extends db
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$userId = (int)$this->user_id;
|
||||
// Get all the keys from the database
|
||||
$sql = "SELECT var, val FROM $this->table WHERE user_id = $this->user_id";
|
||||
$sql = "SELECT var, val FROM $this->table WHERE user_id = $userId";
|
||||
$result = $db->query($sql);
|
||||
$result = $db->fetch_all($result);
|
||||
$keys = [];
|
||||
@@ -111,8 +115,23 @@ class user_key_value_pairs_o extends db
|
||||
public function getCustomerNumbersWithKey(array $keys): array
|
||||
{
|
||||
global $db;
|
||||
$safeKeys = [];
|
||||
foreach ($keys as $key) {
|
||||
if (!is_scalar($key)) {
|
||||
continue;
|
||||
}
|
||||
$key = trim((string)$key);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$safeKeys[] = "'" . $db->escape_string($key) . "'";
|
||||
}
|
||||
$safeKeys = array_values(array_unique($safeKeys));
|
||||
if (empty($safeKeys)) {
|
||||
return [];
|
||||
}
|
||||
// Get all the users, and their customer numbers, with the given keys
|
||||
$sql = "SELECT user_id, val FROM $this->table WHERE var IN ('" . implode("','", $keys) . "')";
|
||||
$sql = "SELECT user_id, val FROM $this->table WHERE var IN (" . implode(',', $safeKeys) . ")";
|
||||
$result = $db->query($sql);
|
||||
$result = $db->fetch_all($result);
|
||||
$user_ids = [];
|
||||
@@ -124,6 +143,10 @@ class user_key_value_pairs_o extends db
|
||||
}
|
||||
$user_ids[] = (int)$row['user_id'];
|
||||
}
|
||||
$user_ids = array_values(array_unique($user_ids));
|
||||
if (empty($user_ids)) {
|
||||
return [];
|
||||
}
|
||||
// Get the customer numbers from the users table
|
||||
$sql = "SELECT id, customer_number FROM users WHERE id IN (" . implode(',', $user_ids) . ")";
|
||||
$result = $db->query($sql);
|
||||
@@ -136,4 +159,4 @@ class user_key_value_pairs_o extends db
|
||||
return $customer_numbers;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\customer_name_cache_payload_builder;
|
||||
use classes\object_property;
|
||||
use classes\redis;
|
||||
use classes\response;
|
||||
use classes\system_search_economic_customer_index;
|
||||
use classes\xlvask;
|
||||
use customers\economic_customer_mo;
|
||||
use customers\economicCustomers;
|
||||
@@ -47,6 +49,10 @@ class users_o extends db
|
||||
$this->setTable('users');
|
||||
}
|
||||
|
||||
private static function redisCache(): ?redis
|
||||
{
|
||||
return defined('redis') ? constant('redis') : null;
|
||||
}
|
||||
|
||||
public function edit(int $id, string $customer_number, string|null $role, string|null $password, string|null $display_name): void
|
||||
{
|
||||
@@ -58,12 +64,12 @@ class users_o extends db
|
||||
if ($old_res && $old_res->num_rows > 0) {
|
||||
$old_cn = (int)$old_res->fetch_assoc()['customer_number'];
|
||||
if ($old_cn !== 0 && $old_cn !== (int)$customer_number) {
|
||||
redis->clear_user_id_from_customer_number($old_cn);
|
||||
self::redisCache()?->clear_user_id_from_customer_number($old_cn);
|
||||
}
|
||||
}
|
||||
// Cache the mapping from customer_number to user_id (new value)
|
||||
redis->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
redis->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
|
||||
// Avoid SQL injection
|
||||
$customer_number = $db->escape_string($customer_number);
|
||||
@@ -234,8 +240,8 @@ class users_o extends db
|
||||
$this->id = (int)$db->insert_id();
|
||||
|
||||
// Cache the mapping from customer_number to user_id
|
||||
redis->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
redis->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
|
||||
// Set the values of the object properties
|
||||
$this->getObjectProperties();
|
||||
@@ -319,21 +325,47 @@ class users_o extends db
|
||||
} else {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
// Check if the user id is set in the request
|
||||
if (isset($data['user_id'])) {
|
||||
return $this->getUserById((int)$data['user_id']);
|
||||
} elseif (isset($data['customer_number'])) {
|
||||
return $this->getUserByCustomerNumber($data['customer_number']);
|
||||
} else {
|
||||
if (!is_array($data)) {
|
||||
return $this;
|
||||
}
|
||||
// Check if the user id is set in the request
|
||||
$user_id = $this->parsePositiveIntFromRequest($data['user_id'] ?? null);
|
||||
if ($user_id !== null) {
|
||||
return $this->getUserById($user_id);
|
||||
}
|
||||
|
||||
$customer_number = $this->parsePositiveIntFromRequest($data['customer_number'] ?? null);
|
||||
if ($customer_number !== null) {
|
||||
return $this->getUserByCustomerNumber($customer_number);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function parsePositiveIntFromRequest(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '' || !ctype_digit($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = (int)$value;
|
||||
return $parsed > 0 ? $parsed : null;
|
||||
}
|
||||
|
||||
public function getUserById(int $id): users_o
|
||||
{
|
||||
global $db;
|
||||
// Check Redis for existence (by checking if we have the customer number)
|
||||
$customer_number = redis->get_customer_number_from_user_id($id);
|
||||
$customer_number = self::redisCache()?->get_customer_number_from_user_id($id);
|
||||
if ($customer_number !== null) {
|
||||
$this->id = $id;
|
||||
$this->getObjectProperties();
|
||||
@@ -347,7 +379,7 @@ class users_o extends db
|
||||
$this->id = $id;
|
||||
$customer_number = (int)$result->fetch_assoc()['customer_number'];
|
||||
// Cache the result
|
||||
redis->cache_customer_number_from_user_id($id, $customer_number);
|
||||
self::redisCache()?->cache_customer_number_from_user_id($id, $customer_number);
|
||||
$this->getObjectProperties();
|
||||
}
|
||||
return $this;
|
||||
@@ -357,7 +389,7 @@ class users_o extends db
|
||||
{
|
||||
global $db;
|
||||
// Check Redis first
|
||||
$user_id = redis->get_user_id_from_customer_number($customer_number);
|
||||
$user_id = self::redisCache()?->get_user_id_from_customer_number($customer_number);
|
||||
if ($user_id !== null) {
|
||||
$this->id = (int)$user_id;
|
||||
$this->getObjectProperties();
|
||||
@@ -370,7 +402,7 @@ class users_o extends db
|
||||
if ($result->num_rows > 0) {
|
||||
$this->id = (int)$result->fetch_assoc()['id'];
|
||||
// Cache the result
|
||||
redis->cache_user_id_from_customer_number($customer_number, $this->id);
|
||||
self::redisCache()?->cache_user_id_from_customer_number($customer_number, $this->id);
|
||||
$this->getObjectProperties();
|
||||
} else {
|
||||
// Import the customer
|
||||
@@ -451,28 +483,65 @@ class users_o extends db
|
||||
// Create a temporary user object
|
||||
$tmp_user = new users_o();
|
||||
$tmp_user->getUserByCustomerNumber($customer_number);
|
||||
$fallbackName = null;
|
||||
if ($tmp_user->exists()) {
|
||||
$displayName = $tmp_user->display_name->value();
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
$fallbackName = $displayName;
|
||||
}
|
||||
}
|
||||
// Get the customer name (Check cache first)
|
||||
$cached = $tmp_user->getCached('economic_customer');
|
||||
if (!$cached) {
|
||||
$tmp_user->getCustomerEcocomicData($customer_number);
|
||||
try {
|
||||
$tmp_user->getCustomerEcocomicData($customer_number);
|
||||
} catch (Exception) {
|
||||
return $fallbackName;
|
||||
}
|
||||
$cached = $tmp_user->getCached('economic_customer');
|
||||
}
|
||||
if ($cached) {
|
||||
return $cached->name;
|
||||
$cachePayload = self::buildCustomerNameCachePayload($cached, $fallbackName);
|
||||
if ($cachePayload !== null) {
|
||||
return $cachePayload['name'];
|
||||
}
|
||||
return null;
|
||||
return $fallbackName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name:string}|null
|
||||
*/
|
||||
private static function buildCustomerNameCachePayload(mixed $cached_name, ?string $fallback_name): ?array
|
||||
{
|
||||
return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
|
||||
}
|
||||
|
||||
public function getCustomerEcocomicData(int $customer_number = null): users_o
|
||||
{
|
||||
// Get the customer data from the external source
|
||||
$economic = new economicCustomers();
|
||||
// Check if the customer number is set
|
||||
if (!isset($this->customer_number) && $customer_number === null) {
|
||||
return $this;
|
||||
}
|
||||
$customer_number = $customer_number ?? $this->customer_number->value();
|
||||
$this->economic_customer = (new economic_customer_mo())->getCustomerByCustomerNumber($customer_number);
|
||||
|
||||
$customer_number = (int)($customer_number ?? $this->customer_number->value());
|
||||
if ($customer_number <= 0) {
|
||||
$this->economic_customer = new economic_customer_mo();
|
||||
return $this;
|
||||
}
|
||||
|
||||
$cachedCustomer = $this->getCached('economic_customer');
|
||||
if (is_object($cachedCustomer)) {
|
||||
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
|
||||
if ($cachedCustomerNumber === $customer_number) {
|
||||
$this->economic_customer = (new economic_customer_mo())->parseCustomer($cachedCustomer);
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->economic_customer = (new economic_customer_mo())->getCustomerByCustomerNumber($customer_number);
|
||||
} catch (Exception) {
|
||||
$this->economic_customer = new economic_customer_mo();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -1010,22 +1079,22 @@ class users_o extends db
|
||||
public function clearAllUsersEconomicCustomerDiscountsFromCache(): void
|
||||
{
|
||||
// Get all the cached results matching the pattern 'users_*_economic_customer_discount_percentage'
|
||||
$cached_results = redis->get_keys('users_*_economic_customer_discount_percentage');
|
||||
$cached_results = self::redisCache()?->get_keys('users_*_economic_customer_discount_percentage') ?? [];
|
||||
// Loop through the cached results
|
||||
foreach ( $cached_results as $key ) {
|
||||
// Clear the cached discount percentage
|
||||
redis->delete($key);
|
||||
self::redisCache()?->delete($key);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearAllUsersEconomicCustomerDetailsFromCache(): void
|
||||
{
|
||||
// Get all the cached results matching the pattern 'users_*_economic_customer'
|
||||
$cached_results = redis->get_keys('users_*_economic_customer');
|
||||
$cached_results = self::redisCache()?->get_keys('users_*_economic_customer') ?? [];
|
||||
// Loop through the cached results
|
||||
foreach ( $cached_results as $key ) {
|
||||
// Clear the cached economic customer details
|
||||
redis->delete($key);
|
||||
self::redisCache()?->delete($key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,7 +1102,7 @@ class users_o extends db
|
||||
{
|
||||
self::requireSelected();
|
||||
// Check if the discount percentage is cached
|
||||
$cached_discount_percentage = redis->get_economic_customer_discount_percentage($this->id);
|
||||
$cached_discount_percentage = self::redisCache()?->get_economic_customer_discount_percentage($this->id);
|
||||
if ($cached_discount_percentage !== null) {
|
||||
return $cached_discount_percentage;
|
||||
}
|
||||
@@ -1041,7 +1110,7 @@ class users_o extends db
|
||||
$economic = new economicCustomers();
|
||||
$discount_percentage = $economic->getCustomerDiscountPercentage($this->customer_number->value());
|
||||
// Cache the discount percentage
|
||||
redis->cache_economic_customer_discount_percentage($this->id, $discount_percentage);
|
||||
self::redisCache()?->cache_economic_customer_discount_percentage($this->id, $discount_percentage);
|
||||
return $discount_percentage;
|
||||
}
|
||||
|
||||
@@ -1080,7 +1149,7 @@ class users_o extends db
|
||||
public function isImportedFromEconomic($customerNumber): bool
|
||||
{
|
||||
// Check Redis first
|
||||
$user_id = redis->get_user_id_from_customer_number((int)$customerNumber);
|
||||
$user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber);
|
||||
if ($user_id !== null) {
|
||||
return true;
|
||||
}
|
||||
@@ -1095,7 +1164,7 @@ class users_o extends db
|
||||
public function getUserIdFromEconomic($customerNumber): int
|
||||
{
|
||||
// Check Redis first
|
||||
$user_id = redis->get_user_id_from_customer_number((int)$customerNumber);
|
||||
$user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber);
|
||||
if ($user_id !== null) {
|
||||
return (int)$user_id;
|
||||
}
|
||||
@@ -1105,7 +1174,7 @@ class users_o extends db
|
||||
$id = (int)$user[0]['id'];
|
||||
|
||||
// Cache the result
|
||||
redis->cache_user_id_from_customer_number((int)$customerNumber, $id);
|
||||
self::redisCache()?->cache_user_id_from_customer_number((int)$customerNumber, $id);
|
||||
|
||||
return $id;
|
||||
}
|
||||
@@ -1180,36 +1249,41 @@ class users_o extends db
|
||||
public function getCustomerNumbersWithAttributes(array $attributes): array
|
||||
{
|
||||
global $db;
|
||||
// Create an array to store the customer numbers
|
||||
$user_ids = [];
|
||||
$customer_numbers = [];
|
||||
// Loop through the attributes
|
||||
foreach ( $attributes as $attribute ) {
|
||||
// Get the customer numbers with the attribute
|
||||
$sql = "SELECT user_id FROM customer_attributes WHERE attribute = '$attribute'";
|
||||
$result = $db->query($sql);
|
||||
// Loop through the results
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Add the customer number to the array
|
||||
$user_ids[] = (int)$row['user_id'];
|
||||
$safeAttributes = [];
|
||||
foreach ($attributes as $attribute) {
|
||||
if (!is_scalar($attribute)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Remove duplicates from the array
|
||||
$user_ids = array_unique($user_ids);
|
||||
// Get the customer numbers from the user IDs
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
// Get the customer number from the user ID
|
||||
$sql = "SELECT customer_number FROM $this->table WHERE id = $user_id";
|
||||
$result = $db->query($sql);
|
||||
// Loop through the results
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Add the customer number to the array
|
||||
$customer_numbers[] = (int)$row['customer_number'];
|
||||
$attribute = trim((string)$attribute);
|
||||
if ($attribute === '') {
|
||||
continue;
|
||||
}
|
||||
$safeAttributes[] = "'" . $db->escape_string($attribute) . "'";
|
||||
}
|
||||
// Remove duplicates from the array
|
||||
// Return the customer numbers
|
||||
return array_unique($customer_numbers);
|
||||
$safeAttributes = array_values(array_unique($safeAttributes));
|
||||
if (empty($safeAttributes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
$sql = "SELECT DISTINCT user_id FROM customer_attributes WHERE attribute IN (" . implode(',', $safeAttributes) . ")";
|
||||
$result = $db->query($sql);
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$userIds[] = (int)$row['user_id'];
|
||||
}
|
||||
$userIds = array_values(array_unique($userIds));
|
||||
if (empty($userIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$customerNumbers = [];
|
||||
$sql = "SELECT customer_number FROM $this->table WHERE id IN (" . implode(',', array_map('intval', $userIds)) . ")";
|
||||
$result = $db->query($sql);
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customerNumbers[] = (int)$row['customer_number'];
|
||||
}
|
||||
|
||||
return array_values(array_unique($customerNumbers));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1299,8 +1373,8 @@ class users_o extends db
|
||||
public function getCustomersWithVehicleSubscriptions(): array
|
||||
{
|
||||
global $db;
|
||||
// Get all customers with vehicle subscriptions
|
||||
$sql = "SELECT DISTINCT customer_id FROM customer_vehicles WHERE wash_subscription = 1";
|
||||
// Get all customers with vehicle subscriptions and not fixed pricing
|
||||
$sql = "SELECT DISTINCT customer_id FROM customer_vehicles WHERE wash_subscription = 1 AND customer_id NOT IN (SELECT customer_number FROM customer_fixed_pricing)";
|
||||
$result = $db->query($sql);
|
||||
$customer_numbers = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
@@ -1462,16 +1536,21 @@ class users_o extends db
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<string, int> Map of customer number to customer name
|
||||
*/
|
||||
public function getCustomerNames(array $customer_numbers): array
|
||||
public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array
|
||||
{
|
||||
global $db;
|
||||
$customer_numbers = array_map('intval', $customer_numbers);
|
||||
if (empty($customer_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Look in the cache first
|
||||
$customer_numbers_to_fetch = [];
|
||||
$customer_names_cached = self::getCachedForMultipleObjects('economic_customer_name', $customer_numbers);
|
||||
// Loop through the customer numbers and check if they are cached
|
||||
$customer_names = array_map(function ($cached_name) {
|
||||
return $cached_name ? json_decode($cached_name)->name : null;
|
||||
$cache_payload = self::buildCustomerNameCachePayload($cached_name, null);
|
||||
return $cache_payload['name'] ?? null;
|
||||
}, array_values($customer_names_cached));
|
||||
// Set the names for the cached customer numbers [ "customer_number" => "customer_name" ]
|
||||
$customer_names = array_combine(
|
||||
@@ -1484,29 +1563,49 @@ class users_o extends db
|
||||
$customer_numbers_to_fetch[] = (int)$customer_number;
|
||||
}
|
||||
}
|
||||
$fallback_names = $this->getLocalDisplayNamesByCustomerNumber($customer_numbers_to_fetch);
|
||||
$local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch);
|
||||
if (!$allowExternalFetch) {
|
||||
foreach ($customer_numbers_to_fetch as $customer_number) {
|
||||
$customer_names[(string)$customer_number] = $local_cached_names[$customer_number] ?? $fallback_names[$customer_number] ?? 'Unknown Customer';
|
||||
}
|
||||
return $customer_names;
|
||||
}
|
||||
|
||||
// Fetch the remaining customer names from E-conomic
|
||||
if (count($customer_numbers_to_fetch) > 0) {
|
||||
foreach ( $customer_numbers_to_fetch as $customer_number ) {
|
||||
if (isset($local_cached_names[$customer_number])) {
|
||||
$customer_names[(string)$customer_number] = $local_cached_names[$customer_number];
|
||||
continue;
|
||||
}
|
||||
// Get the customer name from the external source
|
||||
$fallback_name = $fallback_names[$customer_number] ?? null;
|
||||
try {
|
||||
// Try to get the economic customer data cached in the user
|
||||
$tmp_user = new users_o();
|
||||
$tmp_user->getUserByCustomerNumber($customer_number);
|
||||
if ($tmp_user->exists()) {
|
||||
$display_name = $tmp_user->display_name->value();
|
||||
if (is_string($display_name) && trim($display_name) !== '') {
|
||||
$fallback_name = $display_name;
|
||||
}
|
||||
}
|
||||
$cached_name = $tmp_user->getCached('economic_customer');
|
||||
// If not cached, fetch from E-conomic
|
||||
if (!$cached_name) {
|
||||
$tmp_user->getCustomerEcocomicData($customer_number);
|
||||
$cached_name = $tmp_user->getCached('economic_customer');
|
||||
}
|
||||
if ($cached_name) {
|
||||
$customer_names[(string)$customer_number] = $cached_name->name;
|
||||
$cache_payload = self::buildCustomerNameCachePayload($cached_name, $fallback_name);
|
||||
if ($cache_payload !== null) {
|
||||
$customer_names[(string)$customer_number] = $cache_payload['name'];
|
||||
$this->cache('economic_customer_name', $cache_payload, $customer_number);
|
||||
$this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number);
|
||||
}
|
||||
// Cache the name
|
||||
$this->cache('economic_customer_name', $cached_name, $customer_number);
|
||||
$this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number);
|
||||
} catch ( Exception $e ) {
|
||||
// Ignore exceptions
|
||||
$customer_names[(string)$customer_number] = 'Unable to fetch name';
|
||||
$customer_names[(string)$customer_number] = $fallback_name ?? 'Unable to fetch name';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1514,6 +1613,195 @@ class users_o extends db
|
||||
return $customer_names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0)));
|
||||
if (empty($customer_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = "SELECT customer_number, display_name FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customer_number = (int)($row['customer_number'] ?? 0);
|
||||
$display_name = trim((string)($row['display_name'] ?? ''));
|
||||
if ($customer_number > 0 && $display_name !== '') {
|
||||
$names[$customer_number] = $display_name;
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve names from local e-conomic snapshots only. This keeps period/listing
|
||||
* requests fast while still avoiding "Unnamed" fallbacks when a richer cached
|
||||
* e-conomic customer payload already exists.
|
||||
*
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0)));
|
||||
if (empty($customer_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = "SELECT id, customer_number FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return $this->getIndexedEconomicCustomerNamesByCustomerNumber($customer_numbers);
|
||||
}
|
||||
|
||||
$user_ids_by_customer_number = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customer_number = (int)($row['customer_number'] ?? 0);
|
||||
$user_id = (int)($row['id'] ?? 0);
|
||||
if ($customer_number <= 0 || $user_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user_ids_by_customer_number[$customer_number] = $user_id;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
$customer_numbers_by_index = array_keys($user_ids_by_customer_number);
|
||||
$cached_names = $this->getCachedForMultipleObjects('economic_customer', array_values($user_ids_by_customer_number));
|
||||
foreach ($customer_numbers_by_index as $index => $customer_number) {
|
||||
$cached_name = $cached_names[$index] ?? null;
|
||||
$cache_payload = self::buildCustomerNameCachePayload($cached_name, null);
|
||||
if ($cache_payload === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names[$customer_number] = $cache_payload['name'];
|
||||
$this->cache('economic_customer_name', $cache_payload, $customer_number);
|
||||
$this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number);
|
||||
}
|
||||
|
||||
$missing_customer_numbers = array_values(array_diff($customer_numbers, array_keys($names)));
|
||||
if (!empty($missing_customer_numbers)) {
|
||||
foreach ($this->getIndexedEconomicCustomerNamesByCustomerNumber($missing_customer_numbers) as $customer_number => $name) {
|
||||
$cache_payload = self::buildCustomerNameCachePayload((object)['name' => $name], null);
|
||||
if ($cache_payload === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names[$customer_number] = $cache_payload['name'];
|
||||
$this->cache('economic_customer_name', $cache_payload, $customer_number);
|
||||
$this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number);
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function getIndexedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0)));
|
||||
if (empty($customer_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
system_search_economic_customer_index::ensureTable();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT customer_number, economic_name FROM `" . system_search_economic_customer_index::TABLE . "`"
|
||||
. " WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"
|
||||
);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customer_number = (int)($row['customer_number'] ?? 0);
|
||||
$cache_payload = self::buildCustomerNameCachePayload((object)['name' => $row['economic_name'] ?? null], null);
|
||||
if ($customer_number > 0 && $cache_payload !== null) {
|
||||
$names[$customer_number] = $cache_payload['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $cashier_ids
|
||||
* @return array<int, string> Map of cashier id => display name
|
||||
*/
|
||||
public function getCashierNames(array $cashier_ids): array
|
||||
{
|
||||
$cashier_ids = array_values(array_unique(array_filter(array_map('intval', $cashier_ids), static fn(int $id): bool => $id > 0)));
|
||||
if (empty($cashier_ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cache_key = 'cashier_name';
|
||||
$virtual_cache_ids = array_map(static fn(int $id): string => "cashier_$id", $cashier_ids);
|
||||
$cached_values = $this->getCachedForMultipleObjects($cache_key, $virtual_cache_ids);
|
||||
|
||||
$names = [];
|
||||
$missing_ids = [];
|
||||
foreach ($cashier_ids as $index => $cashier_id) {
|
||||
$cached_name = $cached_values[$index] ?? null;
|
||||
if ($cached_name !== null && $cached_name !== '') {
|
||||
$names[$cashier_id] = (string)$cached_name;
|
||||
continue;
|
||||
}
|
||||
$missing_ids[] = $cashier_id;
|
||||
}
|
||||
|
||||
if (!empty($missing_ids)) {
|
||||
$rows = $this->getFieldsWhereIn(
|
||||
['id' => $missing_ids],
|
||||
['id', 'display_name']
|
||||
);
|
||||
$fetched_names = [];
|
||||
foreach ($rows as $row) {
|
||||
$cashier_id = (int)($row['id'] ?? 0);
|
||||
if ($cashier_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$display_name = trim((string)($row['display_name'] ?? ''));
|
||||
$fetched_names[$cashier_id] = ($display_name !== '') ? $display_name : 'Unknown Cashier';
|
||||
}
|
||||
|
||||
foreach ($missing_ids as $cashier_id) {
|
||||
$resolved_name = $fetched_names[$cashier_id] ?? 'Unknown Cashier';
|
||||
$names[$cashier_id] = $resolved_name;
|
||||
$virtual_cache_object_id = "cashier_$cashier_id";
|
||||
$this->cache($cache_key, $resolved_name, $virtual_cache_object_id);
|
||||
$this->setCachedExpiration($cache_key, self::$cashierNameCacheExpiration, $virtual_cache_object_id);
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
public function getCashierName(int $cashier_id): string
|
||||
{
|
||||
$virtualCacheObjectID = "cashier_$cashier_id";
|
||||
@@ -1562,4 +1850,4 @@ class users_o extends db
|
||||
return "https://truckwash.io/auth/password-reset/" . $token;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace objects;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_usage_logs_schema_bootstrap;
|
||||
use Exception;
|
||||
use helpers\xlvask_customer;
|
||||
use helpers\xlvask_usage_log;
|
||||
@@ -35,9 +36,13 @@ class xlvask_usage_logs_o extends db
|
||||
public object_property $CustomerGuid;
|
||||
public object_property $VehicleId;
|
||||
public object_property $WashItems;
|
||||
public object_property $ignored_at;
|
||||
public object_property $ignored_by;
|
||||
public object_property $ignored_reason;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
$this->setTable('xlvask_usage_logs');
|
||||
}
|
||||
|
||||
@@ -77,6 +82,9 @@ class xlvask_usage_logs_o extends db
|
||||
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
|
||||
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
|
||||
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
|
||||
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
|
||||
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
@@ -84,6 +92,107 @@ class xlvask_usage_logs_o extends db
|
||||
//TODO: Add cache invalidation
|
||||
}
|
||||
|
||||
public function getCachedAmountSummaryFromRow(array $row): array
|
||||
{
|
||||
$cached_amount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null);
|
||||
$cached_at = trim((string)($row['cached_amount_at'] ?? ''));
|
||||
|
||||
if ($cached_amount !== null && $cached_at !== '') {
|
||||
return [
|
||||
'total_net_amount' => $cached_amount,
|
||||
'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''),
|
||||
'cached' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$summary = self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []);
|
||||
$id = (int)($row['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
self::cacheAmountSummary($id, $summary);
|
||||
}
|
||||
|
||||
return [
|
||||
...$summary,
|
||||
'cached' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array
|
||||
{
|
||||
if (is_string($washItems)) {
|
||||
$decoded = json_decode($washItems, true);
|
||||
$washItems = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$total = 0.0;
|
||||
$primaryProductName = '';
|
||||
foreach (is_array($washItems) ? $washItems : [] as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($primaryProductName === '' && isset($item['OriginalProductName'])) {
|
||||
$primaryProductName = trim((string)$item['OriginalProductName']);
|
||||
}
|
||||
|
||||
$priceIncVat = self::normalizeMoneyValue($item['PriceIncVat'] ?? null);
|
||||
$vat = self::normalizeMoneyValue($item['Vat'] ?? 0.0) ?? 0.0;
|
||||
if ($priceIncVat === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total += $priceIncVat - $vat;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_net_amount' => round($total, 2),
|
||||
'primary_product_name' => $primaryProductName,
|
||||
];
|
||||
}
|
||||
|
||||
private static function cacheAmountSummary(int $id, array $summary): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$amount = number_format((float)($summary['total_net_amount'] ?? 0.0), 2, '.', '');
|
||||
$primaryProductName = $db->escape_string((string)($summary['primary_product_name'] ?? ''));
|
||||
$db->query(
|
||||
"UPDATE xlvask_usage_logs
|
||||
SET cached_total_net_amount = {$amount},
|
||||
cached_primary_product_name = " . ($primaryProductName === '' ? 'NULL' : "'{$primaryProductName}'") . ",
|
||||
cached_amount_at = NOW()
|
||||
WHERE id = {$id}"
|
||||
);
|
||||
}
|
||||
|
||||
private static function normalizeMoneyValue(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (float)$value;
|
||||
}
|
||||
|
||||
$normalized = preg_replace('/[^\d,.\-]/', '', (string)$value);
|
||||
if ($normalized === null || $normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($normalized, ',') && !str_contains($normalized, '.')) {
|
||||
$normalized = str_replace(',', '.', $normalized);
|
||||
} else {
|
||||
$normalized = str_replace(',', '', $normalized);
|
||||
}
|
||||
|
||||
return is_numeric($normalized) ? (float)$normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the usage logs from XL Vask
|
||||
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
|
||||
@@ -150,4 +259,4 @@ class xlvask_usage_logs_o extends db
|
||||
));
|
||||
return $vehicles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user