Merge origin/master and resolve orders_o conflict

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 20:41:18 +00:00
committed by GitHub
1991 changed files with 315993 additions and 7850 deletions
@@ -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;
}
}
+59 -13
View File
@@ -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']);
});
}
}
}
+135 -25
View File
@@ -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)),
];
}
}
+2 -1
View File
@@ -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());
+649 -78
View File
@@ -5,6 +5,8 @@ namespace objects;
use attachments\helpers\attachment_content;
use classes\db;
use classes\email;
use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap;
use classes\pdf_generator;
use classes\motorapi;
use classes\object_property;
@@ -30,6 +32,7 @@ class orders_o extends db
public object_property $reg_3;
public economic_module_orders $economic_module_orders;
public object_property $created_at;
public object_property $include_in_invoice;
public object_property $deleted_at;
public object_property $completed_at;
@@ -39,6 +42,7 @@ class orders_o extends db
public object_property $wash_id; // The XL Vask Wash ID, if any
public object_property $lane; // The lane used for the order, if any
public object_property $po; // The (optional) PO number, filled by the customer.
public object_property $safety_seal; // The optional safety seal value for wash certificates.
public object_property $using_hand_held; // Whether the order is being processed using a handheld device
/**
@@ -52,6 +56,7 @@ class orders_o extends db
public function structure(): void
{
orders_schema_bootstrap::ensureTables();
$this->setTable('orders');
}
@@ -85,6 +90,7 @@ class orders_o extends db
$this->reg_2 = new object_property($this->table, $this->id, 'reg_2', 'string', false);
$this->reg_3 = new object_property($this->table, $this->id, 'reg_3', 'string', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'timestamp', false);
$this->economic_module_orders = (new economic_module_orders())->getByOrderId($this->id);
$this->stripe_module_orders = (new stripe_module_orders_o())->select($this->id);
@@ -94,6 +100,7 @@ class orders_o extends db
$this->wash_id = new object_property($this->table, $this->id, 'wash_id', 'string', false);
$this->lane = new object_property($this->table, $this->id, 'lane', 'string', false);
$this->po = new object_property($this->table, $this->id, 'po', 'string', false);
$this->safety_seal = new object_property($this->table, $this->id, 'safety_seal', 'string', false);
$this->using_hand_held = new object_property($this->table, $this->id, 'using_hand_held', 'bool', false);
}
@@ -147,6 +154,80 @@ class orders_o extends db
// Save the object
}
/**
* Return the data needed to decide whether deletion needs explicit confirmation.
*
* @return array{
* requires_confirmation: bool,
* protected_reasons: array<int, string>,
* order_item_count: int,
* attachment_count: int,
* completed_at: mixed
* }
* @throws Exception
*/
public function getDeleteProtectionSummary(): array
{
self::requireSelected();
$completedAt = $this->completed_at->value();
$orderItemCount = $this->countActiveOrderItems();
$attachmentCount = $this->countActiveOrderAttachments();
$protectedReasons = [];
if ($completedAt !== null) {
$protectedReasons[] = 'completed';
}
if ($orderItemCount > 0) {
$protectedReasons[] = 'order_items';
}
if ($attachmentCount > 0) {
$protectedReasons[] = 'attachments';
}
return [
'requires_confirmation' => count($protectedReasons) > 0,
'protected_reasons' => $protectedReasons,
'order_item_count' => $orderItemCount,
'attachment_count' => $attachmentCount,
'completed_at' => $completedAt,
];
}
/**
* @throws Exception
*/
private function countActiveOrderItems(): int
{
self::requireSelected();
global $db;
$orderId = (int)$this->id;
$result = $db->query("SELECT COUNT(*) AS total FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL");
if ($result && $row = $result->fetch_assoc()) {
return (int)($row['total'] ?? 0);
}
return 0;
}
/**
* @throws Exception
*/
private function countActiveOrderAttachments(): int
{
self::requireSelected();
global $db;
$orderId = (int)$this->id;
$result = $db->query("SELECT COUNT(*) AS total FROM object_attachments WHERE object_type = 'orders' AND object_id = {$orderId} AND deleted_at IS NULL");
if ($result && $row = $result->fetch_assoc()) {
return (int)($row['total'] ?? 0);
}
return 0;
}
/**
* @throws Exception If the order is not selected
* This function is called when the order object is changed.
@@ -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;
}
}
+141 -15
View File
@@ -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;
}
}
+36 -2
View File
@@ -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);
}
}
+83 -27
View File
@@ -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;
}
}
+60 -6
View File
@@ -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;
}
}
}
+358 -70
View File
@@ -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;
}
}
}