Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard dc22976873 Fix self-serve cron registry test 2026-07-09 11:35:41 +02:00
Jeppe B 7a1c444df0 Activate self-serve opening relays
Activate self-serve opening relays
2026-07-09 11:27:32 +02:00
Jeppe Bundgaard 6a00f023b1 Refactor cron scheduling 2026-07-09 11:04:13 +02:00
Jeppe B 8aefbd8fb3 Guard wash subscription distribution SQL
Guard the wash subscription distribution query after invoice-inclusion filtering removes all candidate orders, preventing an empty IN () clause on the invoicing distribution endpoint.

Verified with focused syntax, Pest, PHPStan, and invoicing unit-suite checks.
2026-07-09 10:24:02 +02:00
Jeppe B a7181a4ab2 Fix edge gateway relay binding reactivation
Reactivate existing relay binding rows when a gateway/relay pair is re-added after soft deletion, avoiding duplicate uniq_edge_gateway_binding inserts. Add regression coverage for the reactivation path.
2026-07-08 18:46:33 +02:00
Jeppe Bundgaard fc6c76ad1b Add customer rule cleanup for spotfree addon products with preview and apply functionality 2026-07-08 14:40:46 +02:00
Jeppe Bundgaard 6a694f92cc Add bulk action preview and apply endpoints for collected invoices 2026-07-08 13:46:31 +02:00
Jeppe Bundgaard b7a2dc04d7 Merge remote-tracking branch 'origin/master' 2026-07-08 13:03:49 +02:00
Jeppe Bundgaard 870b88e707 Add assertError method to ApiResponse and enhance CI test failure handling 2026-07-08 13:03:33 +02:00
Jeppe B e14cddc1fb Fix API suite regressions 2026-07-08 12:54:26 +02:00
Jeppe B 31887fa8c9 Cover API CI skip prevention wiring 2026-07-08 12:32:27 +02:00
Jeppe B eac83b18a0 Preflight required extensions for API CI 2026-07-08 12:32:19 +02:00
Jeppe B 3817a37021 Make API CI fail on skipped bootstrap 2026-07-08 12:31:40 +02:00
Jeppe Bundgaard 940a3e5e9b Refactor superuser grant selection logic to prioritize the most recently updated grant 2026-07-08 12:16:22 +02:00
Jeppe Bundgaard 3221223865 Enhance vehicle summary retrieval for customers and update OpenAPI schema for subuser management grants 2026-07-08 12:15:18 +02:00
Jeppe Bundgaard b51006d9d1 Refactor subuser management payload and enhance grant deduplication logic 2026-07-08 12:13:12 +02:00
Jeppe Bundgaard 6b7592921d Add subuser permission templates service and related tests 2026-07-08 11:49:40 +02:00
Jeppe Bundgaard f26a427510 Add test for customer booking sessions to retrieve single-product final pricing with context 2026-07-08 10:41:56 +02:00
Jeppe Bundgaard 6de747252f Add department pricing normalization for order bookings 2026-07-08 10:35:59 +02:00
Jeppe Bundgaard ff225ff5e7 Refactor getTargetItems method to include customer and department parameters for improved item normalization 2026-07-08 10:35:39 +02:00
Jeppe Bundgaard dcef993f12 Implement department wash count service and refactor related reporting functions 2026-07-08 10:35:24 +02:00
Jeppe Bundgaard 23aca449f7 Add department wash count service for tracking wash counts and transaction summaries 2026-07-08 10:25:03 +02:00
Jeppe Bundgaard 31b5ba136a Add user-scoped routes for managing subusers and their grants 2026-07-08 10:24:50 +02:00
Jeppe Bundgaard f0b5479f30 Enhance department pricing functionality and improve related tests 2026-07-08 09:35:40 +02:00
Jeppe B b77efc538a Fix backend test gates and department product access 2026-07-07 22:16:37 +02:00
Jeppe Bundgaard 10d1eb5bac Refactor system search cache handling and update OpenAPI specifications for max results 2026-07-07 18:59:07 +02:00
Jeppe Bundgaard 084435e9b8 Add customer pricing permissions and update related tests 2026-07-07 17:58:39 +02:00
Jeppe Bundgaard 172a21c517 Implement department-specific customer pricing functionality 2026-07-07 17:27:56 +02:00
Jeppe Bundgaard c24428e4c7 Update ProductsApiTest to use 'user' session for department pricing tests 2026-07-07 14:38:41 +02:00
Jeppe Bundgaard bf1d6a583e Allow customers to read own attributes 2026-07-07 13:05:24 +02:00
Jeppe Bundgaard 08ac16e665 Fix customer wash certificate access and emails 2026-07-07 12:37:19 +02:00
Jeppe Bundgaard 79185a3c76 Enhance product listing for customer booking sessions: allow access to booking-visible products without requiring additional permissions 2026-07-07 12:23:56 +02:00
Jeppe Bundgaard 3a730e3507 Merge limited backoffice role permission templates 2026-07-07 03:27:39 +02:00
Jeppe Bundgaard ce43c4e064 Expose limited backoffice role permission templates 2026-07-07 03:23:42 +02:00
Jeppe B 579ddcf510 Merge pull request #307 from copenhagentruckwash/copilot/update-limited-backoffice-roles
Fix limited-backoffice role permissions and enforce department access on order mutations
2026-07-07 02:53:06 +02:00
Jeppe Bundgaard 0b342a7780 Align limited backoffice permission cap tests 2026-07-07 02:47:49 +02:00
copilot-swe-agent[bot] 57bcbaf72a Fix 11 failing API tests across 4 files 2026-07-07 00:31:43 +00:00
copilot-swe-agent[bot] d9fbba3130 Return 404 when order item not found in DELETE /order/items 2026-07-06 23:19:00 +00:00
copilot-swe-agent[bot] e4465d9d91 Improve DELETE /order/items: clearer error message, 404 when order not found 2026-07-06 23:17:50 +00:00
copilot-swe-agent[bot] 734cd13c87 Handle prepared statement failure with error response in DELETE /order/items 2026-07-06 23:16:55 +00:00
copilot-swe-agent[bot] d0f94ac549 Use prepared statements for all new DB queries in tests and route 2026-07-06 23:15:59 +00:00
copilot-swe-agent[bot] 1d25cbe21c Fix SQL injection concerns: use prepared statements in orderItemsRoute and tests 2026-07-06 23:14:34 +00:00
copilot-swe-agent[bot] 53d0636193 Fix limited-backoffice permissions and add department access restrictions 2026-07-06 23:12:01 +00:00
Jeppe B 04bb26f1b0 Merge pull request #306 from copenhagentruckwash/copilot/fix-php-api-job-failure
Fix two failing LimitedBackofficeApiTest assertions
2026-07-07 00:44:53 +02:00
copilot-swe-agent[bot] df0d4783d0 Remove search_customers and search_vehicles from raw-permission exclusion list 2026-07-06 22:37:40 +00:00
copilot-swe-agent[bot] 39c06ceab6 Address code review: restore filter passthrough and add clarifying comments 2026-07-06 22:28:45 +00:00
copilot-swe-agent[bot] 7dd428d18e Fix two failing LimitedBackofficeApiTest tests
Fix 1: Remove add_order_attachments and download_order_attachments from
the raw-permissions exclusion check in the test. These strings are valid
capability names that legitimately appear in the /limited-backoffice/roles
response, so including them in the 'should not contain' list caused a
false failure.

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
119 changed files with 11623 additions and 2529 deletions
+830 -74
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_schedule
{
public static function normalize(array $schedule): array
{
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
if ($type !== 'interval') {
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
}
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
if ($seconds < 30 || $seconds > 2678400) {
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
}
return [
'type' => 'interval',
'seconds' => $seconds,
];
}
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
{
$normalized = self::normalize($schedule);
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
? strtotime($anchorDateTime)
: false;
$base = $anchor !== false ? (int)$anchor : $now;
$next = $base + (int)$normalized['seconds'];
if ($next <= $now) {
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
$next += $missed * (int)$normalized['seconds'];
}
return date('Y-m-d H:i:s', $next);
}
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
{
$normalized = self::normalize($schedule);
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
return self::nextRunAt($normalized, $lastRunAt, $now);
}
if ($legacyLastRun !== null && $legacyLastRun > 0) {
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
}
return date('Y-m-d H:i:s', $now);
}
}
@@ -0,0 +1,464 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class cron_scheduler
{
private cron_task_registry $registry;
private string $lock_owner;
public function __construct(?cron_task_registry $registry = null)
{
$this->registry = $registry ?? new cron_task_registry();
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
}
public function listTasks(): array
{
$this->ensureReady();
$this->syncDefinitions();
$states = $this->stateRows();
$estimates = $this->durationEstimates();
$tasks = [];
$now = time();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
? $state['schedule']
: $definition->schedule;
$nextRunAt = $state['next_run_at'] ?? null;
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
}
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
$tasks[] = $task;
}
return [
'tasks' => $tasks,
'summary' => [
'total' => count($tasks),
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
],
];
}
public function listRuns(?string $task_id = null, int $limit = 50): array
{
$this->ensureReady();
$limit = max(1, min(200, $limit));
$where = '';
if ($task_id !== null && trim($task_id) !== '') {
$where = "WHERE task_id = " . $this->sql($task_id);
}
return $this->fetchAll(
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
);
}
public function runDue(string $source = 'automatic'): array
{
$this->ensureReady();
$this->syncDefinitions();
$ran = [];
$now = time();
$states = $this->stateRows();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
continue;
}
$nextRunAt = (string)($state['next_run_at'] ?? '');
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
continue;
}
try {
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => $definition->id,
'module' => $definition->module,
'source' => $source,
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
return [
'ran' => $ran,
'count' => count($ran),
];
}
public function runTask(
string $task_id_or_legacy_name,
string $source = 'manual',
?int $actor_user_id = null,
bool $force = false,
?string $scheduled_for = null
): array {
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if (!$this->claimLock($definition)) {
throw new RuntimeException('Cron task is already running.');
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at);
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
$status = 'succeeded';
$summary = [];
$error_message = null;
$output = '';
try {
if (function_exists('set_time_limit')) {
@set_time_limit($definition->timeout_seconds + 30);
}
$this->ensureLegacyFunctionsLoaded($definition);
if (!is_callable($definition->handler)) {
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
}
ob_start();
$result = call_user_func($definition->handler);
$output = (string)ob_get_clean();
$summary = is_array($result) ? $result : [];
} catch (Throwable $throwable) {
if (ob_get_level() > 0) {
$output .= (string)ob_get_clean();
}
$status = 'failed';
$error_message = $throwable->getMessage();
}
$completed = microtime(true);
$duration_ms = (int)round(($completed - $started) * 1000);
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
$status = 'timed_out';
$error_message = 'Task exceeded its configured timeout window.';
}
if ($output !== '') {
$summary['output'] = substr($output, 0, 8000);
}
$completed_at = date('Y-m-d H:i:s', (int)$completed);
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at);
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
return $run;
}
public function updateTaskConfig(string $task_id, array $config): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$updates = [];
if (array_key_exists('enabled', $config)) {
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
}
if (array_key_exists('schedule', $config)) {
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
}
if ($updates !== []) {
$this->query(
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
);
}
return $this->listTasks();
}
private function ensureReady(): void
{
cron_schema_bootstrap::ensureTables();
}
private function syncDefinitions(): void
{
$now = time();
foreach ($this->registry->definitions() as $definition) {
$row = $this->fetchOne(
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
);
if ($row !== null) {
continue;
}
$legacyLastRun = $this->legacyLastRun($definition);
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
$this->query(
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. ($definition->enabled ? '1' : '0') . ', NULL, '
. $this->sql($nextRunAt)
. ")"
);
}
}
private function legacyLastRun(cron_task_definition $definition): ?int
{
if ($definition->legacy_name === null || !defined('redis')) {
return null;
}
try {
$last_run = redis->get_last_crond_run($definition->legacy_name);
return $last_run !== null ? (int)$last_run : null;
} catch (Throwable) {
return null;
}
}
private function claimLock(cron_task_definition $definition): bool
{
$now = date('Y-m-d H:i:s');
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
$this->query(
"UPDATE cron_task_state
SET locked_until = " . $this->sql($locked_until) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
);
return $this->affectedRows() === 1;
}
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
{
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
if ($schedule === []) {
$schedule = $definition->schedule;
}
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
if ($status !== 'succeeded') {
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
}
$this->query(
"UPDATE cron_task_state
SET last_run_at = " . $this->sql($completed_at) . ",
next_run_at = " . $this->sql($nextRunAt) . ",
locked_until = NULL,
lock_owner = NULL,
current_run_id = NULL,
last_status = " . $this->sql($status) . ",
last_error = " . $this->nullableSql($error_message) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
if ($definition->legacy_name !== null && defined('redis')) {
try {
redis->set_last_crond_run($definition->legacy_name, time());
} catch (Throwable) {
}
}
}
private function createRun(
cron_task_definition $definition,
string $source,
?int $actor_user_id,
?string $scheduled_for,
string $started_at
): int {
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. $this->sql($source) . ", 'running', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->nullableSql($scheduled_for) . ', '
. $this->sql($started_at) . ', '
. $this->sql($this->lock_owner)
. ")"
);
return $this->insertId();
}
private function completeRun(
int $run_id,
string $status,
string $completed_at,
int $duration_ms,
array $summary,
?string $error_message
): void {
$this->query(
"UPDATE cron_task_runs
SET status = " . $this->sql($status) . ",
completed_at = " . $this->sql($completed_at) . ",
duration_ms = " . (string)$duration_ms . ",
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
error_message = " . $this->nullableSql($error_message) . "
WHERE id = " . (string)$run_id
);
}
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
{
if (function_exists($definition->handler)) {
return;
}
if (!defined('WD')) {
return;
}
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
}
require_once WD . '/cron/Cron.php';
}
/**
* @return array<string, array<string, mixed>>
*/
private function stateRows(): array
{
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
$states = [];
foreach ($rows as $row) {
$row['enabled'] = (bool)$row['enabled'];
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
$states[(string)$row['task_id']] = $row;
}
return $states;
}
/**
* @return array<string, int>
*/
private function durationEstimates(): array
{
$rows = $this->fetchAll(
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
FROM (
SELECT task_id, duration_ms
FROM cron_task_runs
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
ORDER BY id DESC
LIMIT 500
) recent_runs
GROUP BY task_id"
);
$estimates = [];
foreach ($rows as $row) {
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
}
return $estimates;
}
private function decodeJson(mixed $json): array
{
if (!is_string($json) || trim($json) === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function fetchOne(string $sql): ?array
{
$rows = $this->fetchAll($sql);
return $rows[0] ?? null;
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function nullableSql(?string $value): string
{
return $value === null ? 'NULL' : $this->sql($value);
}
private function affectedRows(): int
{
global $db;
return (int)$db->conn()->affected_rows;
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
}
@@ -0,0 +1,66 @@
<?php
namespace classes;
class cron_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_state (
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
module VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
schedule_json LONGTEXT NULL,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
locked_until DATETIME NULL,
lock_owner VARCHAR(191) NULL,
current_run_id BIGINT UNSIGNED NULL,
last_status VARCHAR(32) NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_state_next_run (enabled, next_run_at),
KEY idx_cron_task_state_lock (locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_runs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(191) NOT NULL,
module VARCHAR(64) NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
actor_user_id INT NULL,
scheduled_for DATETIME NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
duration_ms INT UNSIGNED NULL,
summary_json LONGTEXT NULL,
error_message TEXT NULL,
lock_owner VARCHAR(191) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_runs_task_created (task_id, created_at),
KEY idx_cron_task_runs_status_created (status, created_at),
KEY idx_cron_task_runs_module_created (module, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_definition
{
public string $id;
public string $name;
public string $description;
public string $module;
public string $handler;
public array $schedule;
public bool $enabled;
public int $timeout_seconds;
public int $estimated_duration_ms;
public int $priority;
public ?string $legacy_name;
public function __construct(array $definition)
{
$this->id = self::requiredString($definition, 'id');
$this->name = self::requiredString($definition, 'name');
$this->description = (string)($definition['description'] ?? '');
$this->module = self::requiredString($definition, 'module');
$this->handler = self::requiredString($definition, 'handler');
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
$this->enabled = (bool)($definition['enabled'] ?? true);
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
$this->priority = (int)($definition['priority'] ?? 100);
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
}
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
}
}
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
{
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
? $state['schedule']
: $this->schedule;
$enabled = array_key_exists('enabled', $state ?? [])
? (bool)$state['enabled']
: $this->enabled;
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'module' => $this->module,
'handler' => $this->handler,
'schedule' => $schedule,
'default_schedule' => $this->schedule,
'enabled' => $enabled,
'default_enabled' => $this->enabled,
'timeout_seconds' => $this->timeout_seconds,
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
'priority' => $this->priority,
'legacy_name' => $this->legacy_name,
'last_run_at' => $state['last_run_at'] ?? null,
'next_run_at' => $state['next_run_at'] ?? null,
'locked_until' => $state['locked_until'] ?? null,
'lock_owner' => $state['lock_owner'] ?? null,
'current_run_id' => $state['current_run_id'] ?? null,
'last_status' => $state['last_status'] ?? null,
'last_error' => $state['last_error'] ?? null,
];
}
private static function requiredString(array $definition, string $key): string
{
$value = trim((string)($definition[$key] ?? ''));
if ($value === '') {
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
}
return $value;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_registry
{
private string $modules_root;
/** @var array<string, cron_task_definition>|null */
private ?array $definitions = null;
public function __construct(?string $modules_root = null)
{
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
}
/**
* @return array<string, cron_task_definition>
*/
public function definitions(): array
{
if ($this->definitions !== null) {
return $this->definitions;
}
$definitions = [];
foreach ($this->definitionFiles() as $file) {
$module_definitions = require $file;
if (!is_array($module_definitions)) {
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
}
foreach ($module_definitions as $definition) {
$task = new cron_task_definition($definition);
if (isset($definitions[$task->id])) {
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
}
$definitions[$task->id] = $task;
}
}
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
if ($left->priority !== $right->priority) {
return $left->priority <=> $right->priority;
}
return strcmp($left->id, $right->id);
});
$this->definitions = $definitions;
return $definitions;
}
public function get(string $id_or_legacy_name): ?cron_task_definition
{
$normalized = trim($id_or_legacy_name);
if ($normalized === '') {
return null;
}
$definitions = $this->definitions();
if (isset($definitions[$normalized])) {
return $definitions[$normalized];
}
foreach ($definitions as $definition) {
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
return $definition;
}
}
return null;
}
/**
* @return array<int, string>
*/
private function definitionFiles(): array
{
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
sort($files, SORT_STRING);
return $files;
}
}
@@ -12,6 +12,7 @@ class customer_product_rule_service
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
/**
* @return array{rule:string,message:string}|null
@@ -52,7 +53,7 @@ class customer_product_rule_service
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
return $this->violation('restrictSpotFree');
}
@@ -101,6 +102,15 @@ class customer_product_rule_service
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function isSpotFreeProduct(int $productId, string $searchableProduct): bool
{
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
return true;
}
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
@@ -0,0 +1,44 @@
<?php
namespace classes;
/**
* Ensures additive schema for department-scoped customer price overrides.
*/
class department_customer_price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`user_id` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,353 @@
<?php
namespace classes;
use objects\department_customer_price_overrides_o;
use objects\departments_o;
use objects\products_o;
use objects\users_o;
class department_customer_pricing_service
{
/**
* @return array<string, mixed>
*/
public function getPricing(int $departmentId, int $userId): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
return [
'department' => $department,
'customer' => $customer,
'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updatePricing(int $departmentId, int $userId, array $payload): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
}
$overrides = $payload['overrides'] ?? null;
if (!is_array($overrides)) {
throw new limited_backoffice_exception('Overrides are required.', 400);
}
$normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o();
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$normalizedKeys = [];
foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
}
global $db;
$db->conn()->begin_transaction();
try {
$db->query(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
);
foreach ($normalized as $override) {
$overrideObject->setPrice(
$departmentId,
$customer['id'],
(bool)$override['is_category'],
$override['product_or_category_id'],
(int)$override['percentage'],
$override['fixed_price']
);
}
$db->conn()->commit();
} catch (\Throwable) {
$db->conn()->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
}
foreach ($normalized as $override) {
$this->recordVersion($customer, $departmentId, $override);
}
foreach ($existingOverrides as $existingOverride) {
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
if (isset($normalizedKeys[$key])) {
continue;
}
$this->recordVersion($customer, $departmentId, [
'is_category' => (bool)$existingOverride['is_category'],
'product_or_category_id' => $existingOverride['product_or_category_id'],
'percentage' => 0,
'fixed_price' => null,
]);
}
return $this->getPricing($departmentId, $customer['id']);
}
/**
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function department(int $departmentId): array
{
$department = (new departments_o())->getDepartmentById($departmentId);
if (!is_array($department) || empty($department)) {
throw new limited_backoffice_exception('Department not found', 404);
}
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'description' => (string)($department['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{id:int,customer_number:int,display_name:string}
*/
private function customer(int $userId): array
{
$customer = (new users_o())->getUserById($userId);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return [
'id' => (int)$customer->id,
'customer_number' => (int)$customer->customer_number->value(),
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
];
}
/**
* @param array<string, mixed> $department
*/
private function assertEnabled(array $department): void
{
if (!($department['custom_pricing_only'] ?? false)) {
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
'message' => 'Department customer pricing is disabled.',
'code' => 'department_customer_pricing_disabled',
'department' => $department,
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(int $departmentId, int $userId): array
{
global $db;
$sql = "
SELECT
c.`id` AS `category_id`,
c.`name` AS `category_name`,
c.`description` AS `category_description`,
p.*,
pdp.`price` AS `department_price`
FROM `department_categories` dc
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id`
AND pdp.`product_id` = p.`id`
WHERE dc.`department_id` = " . (int)$departmentId . "
AND dc.`deleted_at` IS NULL
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
$result = $db->query($sql);
$rows = $result ? $db->fetch_all($result) : [];
$customer = (new users_o())->getUserById($userId);
$categories = [];
$seen = [];
foreach ($rows as $row) {
$productId = (int)$row['id'];
if (isset($seen[$productId])) {
continue;
}
$seen[$productId] = true;
$categoryId = (int)$row['category_id'];
if (!isset($categories[$categoryId])) {
$categories[$categoryId] = [
'id' => $categoryId,
'name' => (string)$row['category_name'],
'description' => (string)($row['category_description'] ?? ''),
'products' => [],
];
}
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
if ($departmentPrice !== null) {
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
}
$categories[$categoryId]['products'][] = [
'id' => $productId,
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'category' => $categoryId,
'apply_category_discount' => (bool)$row['apply_category_discount'],
'base_price' => (int)$row['price'],
'department_price' => $departmentPrice,
'effective_price' => $effectivePrice,
'missing_department_price' => $departmentPrice === null,
];
}
return array_values($categories);
}
/**
* @param array<int, mixed> $overrides
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
*/
private function normalizeOverrides(int $departmentId, array $overrides): array
{
$normalized = [];
foreach ($overrides as $override) {
if (!is_array($override)) {
throw new limited_backoffice_exception('Invalid override payload.', 400);
}
$isCategory = (bool)($override['is_category'] ?? false);
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
if ($objectId === null || $objectId === '') {
throw new limited_backoffice_exception('Override object is required.', 400);
}
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
if ($percentage === false || $percentage < 0 || $percentage > 100) {
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
}
$fixedPrice = null;
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
if ($fixedPrice === false || $fixedPrice < 0) {
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
}
}
if ($isCategory) {
$fixedPrice = null;
$objectId = (string)$objectId;
if ($objectId !== 'global') {
$this->assertDepartmentCategory($departmentId, $objectId);
}
} else {
$objectId = (int)$objectId;
$this->assertDepartmentProduct($departmentId, $objectId);
}
if ($percentage <= 0 && $fixedPrice === null) {
continue;
}
$key = $this->overrideKey($isCategory, $objectId);
$normalized[$key] = [
'is_category' => $isCategory,
'product_or_category_id' => $objectId,
'percentage' => (int)$percentage,
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
];
}
return array_values($normalized);
}
/**
* @param array{id:int,customer_number:int,display_name:string} $customer
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
*/
private function recordVersion(array $customer, int $departmentId, array $override): void
{
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$customer['id'],
(int)$customer['customer_number'],
(bool)$override['is_category'],
(string)$override['product_or_category_id'],
(int)$override['percentage'],
date('Y-m-d H:i:s'),
'live.department_discount_override.route',
1.0,
false,
[
'route' => 'department_customer_pricing',
'department_id' => $departmentId,
],
$override['fixed_price'],
$departmentId
);
} catch (\Throwable) {
}
}
private function overrideKey(bool $isCategory, int|string $objectId): string
{
return ((int)$isCategory) . ':' . (string)$objectId;
}
private function assertDepartmentProduct(int $departmentId, int $productId): void
{
global $db;
$result = $db->query(
'SELECT p.`id`
FROM `department_categories` dc
INNER JOIN `products` p ON p.`category` = dc.`category_id`
WHERE dc.`department_id` = ' . (int)$departmentId . '
AND dc.`deleted_at` IS NULL
AND p.`id` = ' . (int)$productId . '
LIMIT 1'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Product is not available for this department.', 400);
}
}
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
{
global $db;
$categoryId = $db->escape_string($categoryId);
$result = $db->query(
"SELECT `id`
FROM `department_categories`
WHERE `department_id` = " . (int)$departmentId . "
AND `deleted_at` IS NULL
AND `category_id` = '{$categoryId}'
LIMIT 1"
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Category is not available for this department.', 400);
}
}
}
@@ -0,0 +1,252 @@
<?php
namespace classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
use Exception;
class department_wash_count_service
{
/**
* @throws Exception
*/
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT deduped.department_id,
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(*) AS wash_count
FROM (
SELECT dedupe_key,
department_id,
MIN(counted_at) AS counted_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
) deduped
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
ORDER BY deduped.department_id ASC, hour_bucket ASC";
$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;
}
/**
* @param array<int|string> $department_ids
* @return array{quantity:int,products:int,earnings:int,washes:int}
* @throws Exception
*/
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [
'quantity' => 0,
'products' => 0,
'earnings' => 0,
'washes' => 0,
];
}
$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
FROM orders o
JOIN order_items oi ON oi.order_id = o.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' => $this->countRows($date_start, $date_end, $normalized_department_ids),
];
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{id:int,department_id:int,created_at:string}>
* @throws Exception
*/
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
department_id,
MIN(counted_at) AS created_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
ORDER BY 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> $department_ids
* @throws Exception
*/
private function countRows(string $date_start, string $date_end, array $department_ids): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
{
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
o.id AS entity_id,
o.department_id,
o.created_at AS counted_at,
0 AS source_priority
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
UNION ALL
SELECT CASE
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
ELSE CONCAT('selfserve:', s.id)
END AS dedupe_key,
CASE
WHEN linked_o.id IS NOT NULL THEN linked_o.id
ELSE s.id
END AS entity_id,
COALESCE(linked_o.department_id, s.department_id) AS department_id,
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
1 AS source_priority
FROM selfserve_wash_sessions s
LEFT JOIN orders linked_o
ON linked_o.id = s.order_id
AND linked_o.deleted_at IS NULL
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
AND s.deleted_at IS NULL
AND s.completed_at IS NOT NULL
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
}
/**
* @param array<int|string> $ids
* @return array<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$value = (int)$id;
if ($value > 0) {
$normalized[$value] = $value;
}
}
return array_values($normalized);
}
/**
* @throws Exception
*/
private function validateDateRange(string $date_start, string $date_end): void
{
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');
}
}
}
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic;
}
@@ -388,7 +389,7 @@ class economic_v2_distribution_service
continue;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id);
if ($discount_row === null) {
continue;
}
@@ -1528,7 +1529,7 @@ class economic_v2_distribution_service
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id);
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
$total += $line_price;
@@ -1544,14 +1545,18 @@ class economic_v2_distribution_service
return $total;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
$cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . substr($timestamp, 0, 19);
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
return $this->discount_resolution_cache[$cache_key];
}
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
$scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
? (int)$department_id
: null;
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId);
if ($direct !== null && (
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|| (int)($direct['discount'] ?? 0) > 0
@@ -1563,13 +1568,20 @@ class economic_v2_distribution_service
if ($product !== null) {
$category = (string)$product->category->value();
if ($category !== '') {
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId);
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount;
}
}
}
if ($scopedDepartmentId !== null) {
$global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId);
if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $global_discount;
}
}
return $this->discount_resolution_cache[$cache_key] = null;
}
@@ -60,6 +60,7 @@ class economic_v2_schema_bootstrap
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL DEFAULT NULL,
user_id INT NOT NULL,
customer_number INT NOT NULL,
is_category TINYINT(1) NOT NULL,
@@ -75,6 +76,7 @@ class economic_v2_schema_bootstrap
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_user (user_id),
INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -92,6 +94,14 @@ class economic_v2_schema_bootstrap
);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN department_id INT NULL DEFAULT NULL
AFTER id"
);
}
self::$initialized = true;
}
@@ -136,7 +136,8 @@ class economic_v2_versioning_service
float $confidence = 1.0,
bool $inferred = false,
array $metadata = [],
?int $fixed_price = null
?int $fixed_price = null,
?int $department_id = null
): array {
$identity = [
'user_id' => $user_id,
@@ -144,6 +145,9 @@ class economic_v2_versioning_service
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion(
@@ -240,15 +244,21 @@ class economic_v2_versioning_service
int $customer_number,
bool $is_category,
int|string $object_id,
string $timestamp
string $timestamp,
?int $department_id = null
): ?array {
$identity = [
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
$rows = $this->resolveActiveVersions(
'customer_discount_override_versions',
[
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
],
$identity,
$timestamp,
'effective_from DESC, id DESC',
1
@@ -0,0 +1,617 @@
<?php
namespace classes;
use Exception;
use objects\collected_order_invoices_o;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class invoice_collection_bulk_action_service
{
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
public const ACTION_MERGE = 'merge_collections';
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
private const PREVIEW_TTL_SECONDS = 600;
private const MAX_COLLECTIONS = 100;
private const CONFIRMATION_PHRASES = [
'da' => 'Bekræft',
'en' => 'Confirm',
'sv' => 'Bekräfta',
'no' => 'Bekreft',
'de' => 'Bestätigen',
];
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
{
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
$previewId = $this->previewId();
$preview['preview_id'] = $previewId;
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
$this->cachePreview($previewId, [
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
'locale' => $locale,
'selection_hash' => $preview['selection_hash'],
'preview' => $preview,
]);
return $preview;
}
public function apply(
string $previewId,
string $action,
array $invoiceCollectionIds,
array $options,
string $confirmationText,
int $actorUserId,
string $locale = 'da'
): array {
global $db;
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$cached = $this->getCachedPreview($previewId);
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
}
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
if (trim($confirmationText) !== $expectedConfirmation) {
throw new Exception('Confirmation text does not match.');
}
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
if (!empty($freshPreview['blockers'])) {
throw new Exception('Action cannot be applied while blockers are present.');
}
$db->conn()->begin_transaction();
try {
$result = match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
self::ACTION_QUEUE_ECONOMIC => [
'queued_invoice_collection_ids' => $invoiceCollectionIds,
'changed_count' => count($invoiceCollectionIds),
],
default => throw new Exception('Unsupported action'),
};
(new logs_o())->add(
'orderInvoices',
'global',
1,
$actorUserId,
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
);
$db->conn()->commit();
} catch (\Throwable $e) {
$db->conn()->rollback();
throw $e;
}
$this->deleteCachedPreview($previewId);
return [
...$freshPreview,
'preview' => false,
'result' => $result,
];
}
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
{
$collections = $this->loadCollections($invoiceCollectionIds);
$base = [
'action' => $action,
'preview' => true,
'confirmation_phrase' => $this->confirmationPhrase($locale),
'invoice_collection_ids' => $invoiceCollectionIds,
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
'warnings' => [],
'blockers' => [],
];
return match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
default => throw new Exception('Unsupported action'),
};
}
private function previewCleanCustomerRules(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
$rows = $this->orderItemRows((int)$collection->id);
$violatingItemIds = [];
$includedItemIds = [];
foreach ($rows as $row) {
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
(int)$row['order_id'],
(int)$row['product_id'],
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
);
if ($violation === null) {
continue;
}
$violatingItemIds[] = (int)$row['order_item_id'];
$includedItemIds[] = (int)$row['order_item_id'];
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => (string)$violation['rule'],
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
foreach ($rows as $row) {
$orderItemId = (int)$row['order_item_id'];
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
continue;
}
$includedItemIds[] = $orderItemId;
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => $orderItemId,
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => 'related_to_removed_item',
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewMerge(array $preview, array $collections, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$blockers = [];
if (count($collections) < 2) {
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
}
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
}
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
if (count($customerNumbers) !== 1) {
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
}
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
}
$ordersToMove = [];
foreach ($collections as $collection) {
if ((int)$collection->id === $targetId) {
continue;
}
foreach ($collection->getOrderIds() as $orderIdRow) {
$ordersToMove[] = [
'order_id' => (int)$orderIdRow['id'],
'source_invoice_collection_id' => (int)$collection->id,
'target_invoice_collection_id' => $targetId,
];
}
}
return [
...$preview,
'target_invoice_collection_id' => $targetId,
'orders' => $ordersToMove,
'summary' => [
'collections' => count($collections),
'orders_to_move' => count($ordersToMove),
'changed_count' => count($ordersToMove),
],
'blockers' => $blockers,
];
}
private function previewSplitByMonth(array $preview, array $collections): array
{
$items = [];
$changed = [];
$skipped = [];
foreach ($collections as $collection) {
try {
$item = [
'invoice_collection_id' => (int)$collection->id,
...$collection->previewSplitByOrderMonth(),
];
if (($item['status'] ?? '') === 'changed') {
$changed[] = $item;
} else {
$skipped[] = $item;
}
$items[] = $item;
} catch (\Throwable $e) {
$item = [
'status' => 'skipped',
'invoice_collection_id' => (int)$collection->id,
'reason' => 'not_splittable',
'message' => $e->getMessage(),
];
$skipped[] = $item;
$items[] = $item;
}
}
return [
...$preview,
'items' => $items,
'changed' => $changed,
'skipped' => $skipped,
'summary' => [
'collections' => count($collections),
'changed_count' => count($changed),
'skipped_count' => count($skipped),
],
];
}
private function previewResetHiddenPrices(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
if ((int)$row['include_in_invoice'] !== 0) {
continue;
}
$order = (new orders_o())->select((int)$row['order_id']);
$product = (new products_o())->select((int)$row['product_id']);
if (!$order->exists() || !$product->exists()) {
continue;
}
$newPrice = (int)$order->getCustomerProductPrice($product);
if ((int)$row['price'] === $newPrice) {
continue;
}
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'current_price' => (int)$row['price'],
'new_price' => $newPrice,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewQueueEconomic(array $preview, array $collections): array
{
$blockers = [];
foreach ($collections as $collection) {
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = [
'code' => 'collection_booked',
'invoice_collection_id' => (int)$collection->id,
'message' => 'Invoice collection is already booked.',
];
}
}
return [
...$preview,
'summary' => [
'collections' => count($collections),
'changed_count' => count($collections),
],
'blockers' => $blockers,
];
}
private function applyCleanCustomerRules(array $preview): array
{
global $db;
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
if ($itemIds === []) {
return ['changed_count' => 0, 'order_item_ids' => []];
}
$ids = implode(',', array_map('intval', $itemIds));
$now = date('Y-m-d H:i:s');
$safeNow = $db->escape_string($now);
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
$this->touchOrdersForItems($itemIds);
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
}
private function applyMerge(array $preview, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$moved = [];
foreach ($preview['orders'] ?? [] as $row) {
$order = (new orders_o())->select((int)$row['order_id']);
if (!$order->exists()) {
continue;
}
$order->assignToInvoiceCollection($targetId);
$moved[] = (int)$order->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
}
private function applySplitByMonth(array $preview): array
{
$changed = [];
$skipped = [];
foreach ($preview['items'] ?? [] as $item) {
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
$skipped[] = $item;
continue;
}
try {
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
} catch (\Throwable $e) {
$skipped[] = [
'invoice_collection_id' => $invoiceCollectionId,
'status' => 'skipped',
'message' => $e->getMessage(),
];
}
}
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
}
private function applyResetHiddenPrices(array $preview): array
{
$changed = [];
foreach ($preview['order_items'] ?? [] as $item) {
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
if (!$orderItem->exists()) {
continue;
}
$orderItem->price->set((int)$item['new_price']);
$orderItem->objectChanged();
$changed[] = (int)$orderItem->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
}
private function contentMutationBlockers(collected_order_invoices_o $collection): array
{
$blockers = [];
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
}
if (!empty($collection->external_id->value())) {
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
}
return $blockers;
}
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
{
global $db;
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
$sql = "
SELECT
oi.id AS order_item_id,
oi.order_id,
oi.product_id,
oi.related_item_id,
oi.include_in_invoice,
oi.price,
oi.quantity,
p.name AS product_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.invoice_collection_id = {$invoiceCollectionId}
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
{$hiddenCondition}
ORDER BY o.id ASC, oi.id ASC
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function touchOrdersForItems(array $itemIds): void
{
global $db;
if ($itemIds === []) {
return;
}
$ids = implode(',', array_map('intval', $itemIds));
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
if (!$result) {
return;
}
while ($row = $result->fetch_assoc()) {
$order = (new orders_o())->select((int)$row['order_id']);
if ($order->exists()) {
$order->objectChanged();
}
}
}
private function touchCollections(array $invoiceCollectionIds): void
{
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
if ($collection->exists()) {
$collection->objectChanged();
}
}
}
private function loadCollections(array $invoiceCollectionIds): array
{
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
$collection->requireSelected();
return $collection;
}, $invoiceCollectionIds);
}
private function collectionSummary(collected_order_invoices_o $collection): array
{
return [
'id' => (int)$collection->id,
'customer_number' => (int)$collection->customer_number->value(),
'name' => (string)$collection->name->value(),
'created_at' => (string)$collection->created_at->value(),
'closed_at' => $collection->closed_at->value(),
'booked_invoice_id' => $collection->booked_invoice_id->value(),
'external_id' => $collection->external_id->value(),
'order_count' => (int)$collection->getOrders(true),
];
}
private function normalizeAction(string $action): string
{
$action = trim($action);
if (!in_array($action, [
self::ACTION_CLEAN_CUSTOMER_RULES,
self::ACTION_MERGE,
self::ACTION_SPLIT_BY_MONTH,
self::ACTION_RESET_HIDDEN_PRICES,
self::ACTION_QUEUE_ECONOMIC,
], true)) {
throw new Exception('Invalid invoice collection bulk action.');
}
return $action;
}
private function normalizeInvoiceCollectionIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
if (is_array($id) || is_object($id) || !is_numeric($id)) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$parsed = (int)$id;
if ($parsed < 1 || $parsed > 999999999) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$normalized[$parsed] = $parsed;
}
$normalized = array_values($normalized);
sort($normalized);
if ($normalized === []) {
throw new Exception('invoice_collection_ids must contain at least one id.');
}
if (count($normalized) > self::MAX_COLLECTIONS) {
throw new Exception('Too many invoice collections selected.');
}
return $normalized;
}
private function normalizeOptions(array $options): array
{
if (isset($options['target_invoice_collection_id'])) {
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
}
ksort($options);
return $options;
}
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
{
return hash('sha256', json_encode([
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function confirmationPhrase(string $locale): string
{
$language = strtolower(substr(trim($locale), 0, 2));
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
}
private function previewId(): string
{
return bin2hex(random_bytes(16));
}
private function previewCacheKey(string $previewId): string
{
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
}
private function cachePreview(string $previewId, array $payload): void
{
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
}
private function getCachedPreview(string $previewId): ?array
{
$raw = (new redis())->get($this->previewCacheKey($previewId));
if (!$raw) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function deleteCachedPreview(string $previewId): void
{
(new redis())->delete($this->previewCacheKey($previewId));
}
}
@@ -22,6 +22,7 @@ class invoice_period_flag_service
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
private array $economicCustomerDiscountCache = [];
private array $userDisplayNameCache = [];
private array $orderItemsPreviewCache = [];
@@ -31,6 +32,7 @@ class invoice_period_flag_service
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
department_customer_price_overrides_schema_bootstrap::ensureTables();
}
public function createManualFlag(array $payload, int $userId): array
@@ -712,9 +714,21 @@ class invoice_period_flag_service
p.max_quantity_per_order,
c.name AS category_name,
pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage,
product_discount.fixed_price AS product_fixed_price,
category_discount.percentage AS category_discount_percentage
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
ELSE product_discount.percentage
END AS product_discount_percentage,
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
ELSE product_discount.fixed_price
END AS product_fixed_price,
CASE
WHEN d.custom_pricing_only = 1 THEN GREATEST(
COALESCE(department_category_discount.percentage, 0),
COALESCE(department_global_discount.percentage, 0)
)
ELSE category_discount.percentage
END AS category_discount_percentage
FROM orders o
LEFT JOIN (
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
@@ -745,6 +759,32 @@ class invoice_period_flag_service
) category_discount
ON category_discount.customer_number = o.customer_id
AND category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
FROM department_customer_price_overrides
WHERE is_category = 0
GROUP BY department_id, user_id, product_or_category_id
) department_product_discount
ON department_product_discount.department_id = o.department_id
AND department_product_discount.user_id = u.id
AND department_product_discount.product_or_category_id = p.id
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id <> 'global'
GROUP BY department_id, user_id, product_or_category_id
) department_category_discount
ON department_category_discount.department_id = o.department_id
AND department_category_discount.user_id = u.id
AND department_category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id = 'global'
GROUP BY department_id, user_id
) department_global_discount
ON department_global_discount.department_id = o.department_id
AND department_global_discount.user_id = u.id
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
AND o.deleted_at IS NULL
ORDER BY o.customer_id, o.id, oi.id";
@@ -900,7 +940,7 @@ class invoice_period_flag_service
}
$restrictedProducts = [
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
];
@@ -2058,6 +2098,11 @@ class invoice_period_flag_service
private function rowMatchesProductTerms(array $row, array $terms): bool
{
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
return true;
}
$haystack = strtolower(trim(
(string)($row['product_name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
@@ -11,27 +11,48 @@ class limited_backoffice_service
{
public const PERMISSION_ACCESS = 'limited_backoffice_access';
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
public const PERMISSION_VIEW_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_view';
public const PERMISSION_MANAGE_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_manage';
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
private const MANAGED_EMPLOYEE_CUSTOMER_NUMBER = 0;
/**
* Permissions required for managed employees to sign in and appear in the employee login picker.
* Permissions required for managed employees to sign in, appear in the employee login picker,
* and open the department admin shell used by their scoped role permissions.
*
* @var array<int, string>
*/
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
'admin',
'user',
'permissions_list_own',
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
];
/**
* Permissions that are always granted to managed employees when present in a role preset,
* regardless of whether the creating manager holds those permissions themselves.
*
* @var array<int, string>
*/
private const ROLE_UNCONDITIONAL_PERMISSIONS = [
'list_departments',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
];
/**
* @var array<string, array{label:string,description:string,permissions:array<int,string>}>
*/
private const ROLE_PRESETS = [
'viewer' => [
'label' => 'Viewer',
'description' => 'Can sign in and view assigned department data.',
'label' => 'Deactivated',
'description' => 'Keeps the employee registered without order, booking, or management permissions.',
'permissions' => [
'user',
'permissions_list_own',
@@ -39,18 +60,58 @@ class limited_backoffice_service
],
'cashier' => [
'label' => 'Cashier',
'description' => 'Can work with orders and order lines for assigned departments.',
'description' => 'Can work with POS orders, products, customers, vehicles, attachments, payments, scanners, and bookings for assigned departments.',
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
],
],
'booking_coordinator' => [
@@ -59,16 +120,23 @@ class limited_backoffice_service
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'department_timebookings_entries_get',
'department_timebookings_entries_post',
'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
],
],
'operations_lead' => [
@@ -77,21 +145,53 @@ class limited_backoffice_service
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'delete_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
],
@@ -102,25 +202,59 @@ class limited_backoffice_service
'permissions' => [
'user',
'permissions_list_own',
'list_departments',
'list_orders',
'fetch_order',
'add_order',
'edit_order',
'delete_order',
'mark_order_as_completed',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
self::PERMISSION_ACCESS,
self::PERMISSION_MANAGE_PRICES,
self::PERMISSION_VIEW_CUSTOMER_PRICING,
self::PERMISSION_MANAGE_CUSTOMER_PRICING,
self::PERMISSION_MANAGE_EMPLOYEES,
],
],
@@ -142,6 +276,10 @@ class limited_backoffice_service
'group' => 'orders',
'capability' => 'view_orders',
],
'fetch_order' => [
'group' => 'orders',
'capability' => 'view_orders',
],
'add_order' => [
'group' => 'orders',
'capability' => 'create_orders',
@@ -154,6 +292,10 @@ class limited_backoffice_service
'group' => 'orders',
'capability' => 'delete_orders',
],
'mark_order_as_completed' => [
'group' => 'orders',
'capability' => 'complete_orders',
],
'list_order_items' => [
'group' => 'orders',
'capability' => 'view_order_items',
@@ -170,10 +312,110 @@ class limited_backoffice_service
'group' => 'orders',
'capability' => 'remove_order_lines',
],
'list_order_attachments' => [
'group' => 'attachments',
'capability' => 'view_order_attachments',
],
'add_order_attachments' => [
'group' => 'attachments',
'capability' => 'add_order_attachments',
],
'download_order_attachments' => [
'group' => 'attachments',
'capability' => 'download_order_attachments',
],
'list_products' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_order_recommended' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'vehicle_product_suggestions' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'search_customers' => [
'group' => 'customers',
'capability' => 'search_customers',
],
'get_user_from_customer_number' => [
'group' => 'customers',
'capability' => 'view_customer_details',
],
'list_customer_notes' => [
'group' => 'customers',
'capability' => 'view_customer_notes',
],
'add_customer_note' => [
'group' => 'customers',
'capability' => 'add_customer_notes',
],
'list_customer_attributes' => [
'group' => 'customers',
'capability' => 'view_customer_flags',
],
'search_vehicles' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'view_vehicle_status' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'list_unknown_customer_vehicles' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'list_vehicle_customer_suggestions' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'department_license_plate_lookup' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'department_vehicle_order_last_five' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'list_number_plate_scans' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'list_department_number_plate_scanners' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'charge_order' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'get_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'confirm_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_department_terminal_readers_list' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_invoice_send' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'list_bookings' => [
'group' => 'bookings',
'capability' => 'view_department_bookings',
@@ -190,6 +432,10 @@ class limited_backoffice_service
'group' => 'bookings',
'capability' => 'create_bookings',
],
'add_bookings' => [
'group' => 'bookings',
'capability' => 'create_bookings',
],
'complete_bookings' => [
'group' => 'bookings',
'capability' => 'mark_bookings_complete',
@@ -210,6 +456,22 @@ class limited_backoffice_service
'group' => 'time_bookings',
'capability' => 'edit_time_booking_entries',
],
'list_departments' => [
'group' => 'departments',
'capability' => 'view_departments',
],
'list_department_daily_reports' => [
'group' => 'departments',
'capability' => 'view_daily_reports',
],
'list_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'list_own_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'statistics_orders_new' => [
'group' => 'reports',
'capability' => 'view_order_statistics',
@@ -226,6 +488,14 @@ class limited_backoffice_service
'group' => 'limited_backoffice',
'capability' => 'manage_department_prices',
],
self::PERMISSION_VIEW_CUSTOMER_PRICING => [
'group' => 'limited_backoffice',
'capability' => 'view_customer_pricing',
],
self::PERMISSION_MANAGE_CUSTOMER_PRICING => [
'group' => 'limited_backoffice',
'capability' => 'manage_customer_pricing',
],
self::PERMISSION_MANAGE_EMPLOYEES => [
'group' => 'limited_backoffice',
'capability' => 'manage_employee_access',
@@ -237,9 +507,16 @@ class limited_backoffice_service
*/
private const ROLE_PERMISSION_GROUP_ORDER = [
'account',
'departments',
'orders',
'products',
'customers',
'vehicles',
'attachments',
'scanner',
'bookings',
'time_bookings',
'notifications',
'reports',
'limited_backoffice',
];
@@ -286,6 +563,23 @@ class limited_backoffice_service
return $roles;
}
/**
* @return array<int, array{key:string,label:string,description:string,permissions:array<int,string>}>
*/
public function rolePermissionTemplates(): array
{
$templates = [];
foreach (self::ROLE_PRESETS as $key => $preset) {
$templates[] = [
'key' => $key,
'label' => $preset['label'],
'description' => $preset['description'],
'permissions' => array_values($preset['permissions']),
];
}
return $templates;
}
/**
* @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}>
@@ -333,6 +627,15 @@ class limited_backoffice_service
return [];
}
if ($user->hasPermission('superuser')) {
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
}
global $db;
$statement = $this->mysqli()->prepare(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?'
@@ -504,6 +807,34 @@ class limited_backoffice_service
return $this->getDepartmentPrices($user, $departmentId);
}
/**
* @return array<string, mixed>
*/
public function getDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId): array
{
$this->assertDepartmentAccess($user, $departmentId);
return (new department_customer_pricing_service())->getPricing($departmentId, $customerUserId);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updateDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId, array $payload): array
{
$this->assertDepartmentAccess($user, $departmentId);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
return (new department_customer_pricing_service())->updatePricing($departmentId, $customerUserId, [
...$payload,
'department_id' => $departmentId,
'user_id' => $customerUserId,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -574,7 +905,7 @@ class limited_backoffice_service
try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$customerNumber = $this->generateEmployeeCustomerNumber();
$customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$statement = $mysqli->prepare(
@@ -599,15 +930,7 @@ class limited_backoffice_service
$employeeId = (int)$mysqli->insert_id;
$statement->close();
$groupName = 'Limited employee #' . $employeeId;
$groupDescription = 'Managed by limited backoffice.';
$statement = $mysqli->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
$this->renameManagedGroup($groupId, $employeeId);
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) {
@@ -641,6 +964,72 @@ class limited_backoffice_service
return $this->formatEmployee($employee, $departmentIds, true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function migrateEmployee(users_o $manager, int $employeeId, array $payload): array
{
$this->rejectRawPermissionPayload($payload);
$this->assertNotSelfEdit($manager, $employeeId);
if ($this->loadManagedEmployee($employeeId) !== null) {
throw new limited_backoffice_exception('User is already a limited backoffice employee.', 409);
}
$target = $this->loadMigratableUser($employeeId);
if ($target === null) {
throw new limited_backoffice_exception('User not found.', 404);
}
$this->assertMigrationTargetIsSafe($target);
$departmentIds = $this->normalizeDepartmentIds($payload['department_ids'] ?? null);
$this->assertDepartmentSubset($manager, $departmentIds);
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) {
throw new limited_backoffice_exception('Unable to encode department metadata.', 500);
}
$mysqli = $this->mysqli();
$mysqli->begin_transaction();
try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$this->renameManagedGroup($groupId, $employeeId);
$this->updateUserFields($employeeId, [
'group_id' => $groupId,
]);
$managerId = (int)$manager->id;
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`, `updated_by_user_id`)
VALUES (?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare migrated employee metadata insert.');
}
$statement->bind_param('iissii', $employeeId, $groupId, $roleKey, $departmentJson, $managerId, $managerId);
$statement->execute();
$statement->close();
$this->clearUserSessionCache($employeeId);
$mysqli->commit();
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to migrate employee.', 500);
}
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Unable to load migrated employee.', 500);
}
return $this->formatEmployee($employee, $departmentIds, true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
@@ -1295,6 +1684,19 @@ class limited_backoffice_service
return $groupId;
}
private function renameManagedGroup(int $groupId, int $employeeId): void
{
$groupName = 'Limited employee #' . $employeeId;
$groupDescription = 'Managed by limited backoffice.';
$statement = $this->mysqli()->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
}
/**
* @return array<int, string>
*/
@@ -1308,6 +1710,11 @@ class limited_backoffice_service
continue;
}
if (in_array($permission, self::ROLE_UNCONDITIONAL_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if ($manager->hasPermission($permission)) {
$permissions[] = $permission;
}
@@ -1364,29 +1771,6 @@ class limited_backoffice_service
$insert->close();
}
private function generateEmployeeCustomerNumber(): int
{
$mysqli = $this->mysqli();
for ($attempt = 0; $attempt < 20; $attempt++) {
$customerNumber = random_int(900000000, 999999999);
$statement = $mysqli->prepare('SELECT `id` FROM `users` WHERE `customer_number` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare customer number check.');
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$exists = $result->num_rows > 0;
$statement->close();
if (!$exists) {
return $customerNumber;
}
}
throw new \RuntimeException('Unable to generate employee customer number.');
}
/**
* @return array<string, mixed>|null
*/
@@ -1424,6 +1808,42 @@ class limited_backoffice_service
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>|null
*/
private function loadMigratableUser(int $employeeId): ?array
{
global $db;
$userDeletedAtSelect = $this->tableHasColumn('users', 'deleted_at')
? 'u.`deleted_at` AS `user_deleted_at`'
: 'NULL AS `user_deleted_at`';
$statement = $this->mysqli()->prepare(
'SELECT
u.`id`,
u.`customer_number`,
u.`display_name`,
u.`email`,
u.`phone_country_code`,
u.`phone`,
u.`group_id`,
' . $userDeletedAtSelect . '
FROM `users` u
WHERE u.`id` = ?
LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to load user.', 500);
}
$statement->bind_param('i', $employeeId);
$statement->execute();
$result = $statement->get_result();
$row = $db->fetch_assoc($result);
$statement->close();
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $row
*/
@@ -1518,6 +1938,25 @@ class limited_backoffice_service
}
}
/**
* @param array<string, mixed> $target
*/
private function assertMigrationTargetIsSafe(array $target): void
{
if ((int)($target['customer_number'] ?? -1) !== self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER) {
throw new limited_backoffice_exception('Only employee accounts with customer number 0 can be migrated.', 400);
}
$groupId = (int)($target['group_id'] ?? 0);
if ($groupId === 1 || $this->groupHasPermission($groupId, 'superuser')) {
throw new limited_backoffice_exception('Cannot migrate superuser accounts.', 403);
}
if (($target['user_deleted_at'] ?? null) !== null) {
throw new limited_backoffice_exception('Cannot migrate inactive users.', 409);
}
}
private function groupHasPermission(int $groupId, string $permission): bool
{
if ($groupId <= 0) {
@@ -73,7 +73,9 @@ class selfserve_schema_bootstrap
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number),
INDEX idx_selfserve_wash_sessions_created_at (created_at)
INDEX idx_selfserve_wash_sessions_created_at (created_at),
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
INDEX idx_selfserve_wash_sessions_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
@@ -219,6 +221,16 @@ class selfserve_schema_bootstrap
'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_department_completed',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_order',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)'
);
self::$initialized = true;
}
@@ -252,6 +264,35 @@ class selfserve_schema_bootstrap
$db->query($alterSql);
}
public static function tableHasIndex(string $table, string $index): bool
{
global $db;
$table = $db->escape_string($table);
$index = $db->escape_string($index);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT COUNT(*) AS c
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND INDEX_NAME = '$index'";
$result = $db->query($sql);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
public static function ensureIndex(string $table, string $index, string $alterSql): void
{
global $db;
if (self::tableHasIndex($table, $index)) {
return;
}
$db->query($alterSql);
}
/**
* @param array<int,string> $acceptedDataTypes
*/
@@ -0,0 +1,276 @@
<?php
namespace classes;
use modules\subusers\helpers\subusers_permission_node_key;
class subuser_permission_templates_service
{
public const TEMPLATE_DEACTIVATED = 'deactivated';
public const TEMPLATE_DRIVER = 'driver';
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
public const TEMPLATE_CUSTOM = 'custom';
/**
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
*/
private const TEMPLATES = [
self::TEMPLATE_DEACTIVATED => [
'label' => 'Deactivated',
'description' => 'Keeps the driver linked to the customer without active access.',
'enabled' => false,
'permissions' => [],
],
self::TEMPLATE_DRIVER => [
'label' => 'Driver',
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'SELFSERVE_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'ORDERS_LIST',
],
],
self::TEMPLATE_BOOKING_COORDINATOR => [
'label' => 'Booking coordinator',
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'ORDERS_LIST',
],
],
self::TEMPLATE_FLEET_ADMIN => [
'label' => 'Fleet admin',
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'VEHICLES_EDIT',
'VEHICLES_DELETE',
'VEHICLES_ADD',
'SELFSERVE_LIST',
'SELFSERVE_EDIT',
'SELFSERVE_DELETE',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
'BOOKINGS_ADD',
'ORDERS_LIST',
'ORDERS_EDIT',
'SUBUSERS_LIST',
'SUBUSERS_EDIT',
'SUBUSERS_DELETE',
'SUBUSERS_ADD',
],
],
];
/**
* @var array<string, array{group:string,capability:string}>
*/
private const PERMISSION_CAPABILITIES = [
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
];
/**
* @var array<int, string>
*/
private const GROUP_ORDER = [
'vehicles',
'selfserve',
'bookings',
'orders',
'driver_management',
];
/**
* @return array<string, mixed>
*/
public function accessModel(): array
{
return [
'templates' => $this->templates(),
'groups' => $this->groups(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function templates(): array
{
$templates = [];
foreach (self::TEMPLATES as $key => $template) {
$templates[] = [
'key' => $key,
'label' => $template['label'],
'description' => $template['description'],
'enabled' => $template['enabled'],
'permissions' => array_values($template['permissions']),
'permission_groups' => $this->permissionGroups($template['permissions']),
];
}
return $templates;
}
/**
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function groups(): array
{
$groups = [];
foreach (self::GROUP_ORDER as $group) {
$capabilities = [];
foreach (self::PERMISSION_CAPABILITIES as $capability) {
if ($capability['group'] === $group) {
$capabilities[] = $capability['capability'];
}
}
$groups[] = [
'key' => $group,
'capabilities' => array_values(array_unique($capabilities)),
];
}
return $groups;
}
/**
* @return array{enabled:bool,permissions:array<int,string>}
*/
public function expandTemplate(string $templateKey): array
{
$key = $this->normalizeTemplateKey($templateKey);
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
throw new \InvalidArgumentException('Unknown driver access template.');
}
return [
'enabled' => self::TEMPLATES[$key]['enabled'],
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
];
}
public function normalizeTemplateKey(?string $templateKey): ?string
{
if ($templateKey === null) {
return null;
}
$key = strtolower(trim($templateKey));
if ($key === self::TEMPLATE_CUSTOM) {
return self::TEMPLATE_CUSTOM;
}
return array_key_exists($key, self::TEMPLATES) ? $key : null;
}
/**
* @param array<int, string> $permissions
*/
public function classify(array $permissions, bool $enabled = true): string
{
$normalized = $this->normalizePermissions($permissions);
if (!$enabled || $normalized === []) {
return self::TEMPLATE_DEACTIVATED;
}
foreach (self::TEMPLATES as $key => $template) {
if (!$template['enabled']) {
continue;
}
if ($normalized === $this->normalizePermissions($template['permissions'])) {
return $key;
}
}
return self::TEMPLATE_CUSTOM;
}
/**
* @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function permissionGroups(array $permissions): array
{
$permissions = $this->normalizePermissions($permissions);
$groups = [];
foreach ($permissions as $permission) {
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
if ($capability === null) {
continue;
}
$group = $capability['group'];
$groups[$group] ??= [];
$groups[$group][] = $capability['capability'];
}
$payload = [];
foreach (self::GROUP_ORDER as $group) {
if (!isset($groups[$group])) {
continue;
}
$payload[] = [
'key' => $group,
'capabilities' => array_values(array_unique($groups[$group])),
];
}
return $payload;
}
/**
* @param array<int, string> $permissions
* @return array<int, string>
*/
private function normalizePermissions(array $permissions): array
{
$normalized = [];
foreach ($permissions 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) {
$normalized[] = $permission;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
}
@@ -6,9 +6,8 @@ use Throwable;
class system_search_cache
{
public const PREFIX = 'system_search:v1:';
public const PREFIX = 'system_search:v2:';
public const QUERY_PREFIX = self::PREFIX . 'query:';
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
@@ -40,24 +39,6 @@ class system_search_cache
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function getIntent(string $hash): ?array
{
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
{
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
@@ -68,11 +49,6 @@ class system_search_cache
self::clearPattern(self::QUERY_PREFIX . '*');
}
public static function clearIntentCaches(): void
{
self::clearPattern(self::INTENT_PREFIX . '*');
}
public static function markDirtyTable(string $table): void
{
$table = trim($table, " `\t\n\r\0\x0B");
@@ -1,317 +0,0 @@
<?php
namespace classes;
use Exception;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_openai_intent_parser implements system_search_intent_parser_i
{
private string $apiUrl = 'https://api.openai.com/v1/responses';
private string $model = 'gpt-4.1-mini';
private float $temperature = 0.1;
private int $timeoutSeconds = 10;
private int $maxAliases = 12;
private int $maxEntityHints = 8;
private int $maxAliasLength = 64;
private int $maxHintLength = 32;
private int $maxNormalizedQueryLength = 256;
private int $maxFallbackReasonLength = 160;
/**
* @var callable|null
*/
private $transport;
private ?bool $forcedEnabled;
private ?string $forcedApiKey;
public function __construct(?callable $transport = null, ?bool $forcedEnabled = null, ?string $forcedApiKey = null)
{
$this->transport = $transport;
$this->forcedEnabled = $forcedEnabled;
$this->forcedApiKey = $forcedApiKey;
}
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
$query = trim($query);
if ($query === '') {
return $this->failed('empty_query', 'none');
}
[$enabled, $apiKey] = $this->resolveOpenAISettings();
if (!$enabled) {
return $this->failed('openai_disabled', 'none');
}
if (empty($apiKey)) {
return $this->failed('openai_missing_key', 'none');
}
$redactedQuery = self::redactSensitiveQuery($query);
$payload = $this->buildPayload($redactedQuery, $allowedEntityTypes, $taxonomy);
$cacheHash = md5(json_encode([
'q' => $redactedQuery,
'types' => $allowedEntityTypes,
'taxonomy' => $taxonomy,
'v' => 1,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getIntent($cacheHash);
if (is_array($cached) && isset($cached['success'])) {
$cached['source'] = 'cache';
return $this->normalizeResult($cached, $allowedEntityTypes);
}
try {
$raw = $this->sendRequest($payload, $apiKey);
$parsed = $this->parseResponse($raw);
$parsed['source'] = 'openai';
system_search_cache::setIntent($cacheHash, $parsed, 3600);
return $this->normalizeResult($parsed, $allowedEntityTypes);
} catch (Throwable $e) {
return $this->failed($e->getMessage(), 'openai');
}
}
public static function redactSensitiveQuery(string $query): string
{
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
$query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query;
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
$query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query;
$query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query;
$query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query;
return $query;
}
private function resolveOpenAISettings(): array
{
if ($this->forcedEnabled !== null) {
return [(bool)$this->forcedEnabled, (string)($this->forcedApiKey ?? '')];
}
try {
$openai = new openai();
$enabled = (bool)$openai->config->enabled->getVariableValue();
$apiKey = (string)$openai->config->api_key->getVariableValue();
return [$enabled, $apiKey];
} catch (Throwable) {
return [false, ''];
}
}
private function buildPayload(string $query, array $allowedEntityTypes, array $taxonomy): array
{
$taxonomyText = json_encode([
'allowed_entity_types' => array_values($allowedEntityTypes),
'taxonomy' => $taxonomy,
], JSON_UNESCAPED_UNICODE);
$prompt = "You parse user search intent into strict JSON.\n"
. "Rules:\n"
. "- Keep output concise and valid JSON only.\n"
. "- Do not invent entity types not listed in allowed_entity_types.\n"
. "- Infer what the user is trying to find, not just literal words.\n"
. "- aliases should contain user-friendly and backend-friendly equivalent terms.\n"
. "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n"
. "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n"
. "- confidence must be between 0 and 1.\n"
. "- association_hint should be true if related records likely needed.\n\n"
. "Context:\n"
. $taxonomyText . "\n\n"
. "User query:\n"
. $query;
return [
'model' => $this->model,
'temperature' => $this->temperature,
'input' => [
[
'role' => 'user',
'content' => [
['type' => 'input_text', 'text' => $prompt],
],
],
],
'text' => [
'format' => [
'type' => 'json_schema',
'name' => 'system_search_intent',
'schema' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean'],
'normalized_query' => [
'type' => 'string',
'maxLength' => $this->maxNormalizedQueryLength,
],
'aliases' => [
'type' => 'array',
'maxItems' => $this->maxAliases,
'items' => [
'type' => 'string',
'maxLength' => $this->maxAliasLength,
],
],
'entity_hints' => [
'type' => 'array',
'maxItems' => $this->maxEntityHints,
'items' => [
'type' => 'string',
'maxLength' => $this->maxHintLength,
],
],
'confidence' => [
'type' => 'number',
'minimum' => 0,
'maximum' => 1,
],
'association_hint' => ['type' => 'boolean'],
'fallback_reason' => [
'anyOf' => [
['type' => 'string', 'maxLength' => $this->maxFallbackReasonLength],
['type' => 'null'],
],
],
],
'required' => [
'success',
'normalized_query',
'aliases',
'entity_hints',
'confidence',
'association_hint',
'fallback_reason',
],
'additionalProperties' => false,
],
'strict' => true,
],
],
];
}
private function sendRequest(array $payload, string $apiKey): array
{
if ($this->transport !== null) {
$result = call_user_func($this->transport, $payload, $apiKey);
if (!is_array($result)) {
throw new Exception('Transport returned invalid payload');
}
return $result;
}
$curl = curl_init($this->apiUrl);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
]);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$raw = curl_exec($curl);
if ($raw === false) {
$error = curl_error($curl);
curl_close($curl);
throw new Exception('cURL error: ' . $error);
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new Exception('Invalid JSON from OpenAI');
}
if ($status >= 400) {
$message = $decoded['error']['message'] ?? ('OpenAI HTTP ' . $status);
throw new Exception($message);
}
return $decoded;
}
private function parseResponse(array $response): array
{
$text = $response['output'][0]['content'][0]['text'] ?? null;
if (!is_string($text) || $text === '') {
throw new Exception('Invalid response format (missing output text)');
}
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
throw new Exception('Invalid intent JSON');
}
return $decoded;
}
private function normalizeResult(array $result, array $allowedEntityTypes = []): array
{
$normalizedQuery = trim((string)($result['normalized_query'] ?? ''));
if (mb_strlen($normalizedQuery) > $this->maxNormalizedQueryLength) {
$normalizedQuery = mb_substr($normalizedQuery, 0, $this->maxNormalizedQueryLength);
}
$aliases = $this->sanitizeStringList((array)($result['aliases'] ?? []), $this->maxAliases, $this->maxAliasLength);
$entityHints = $this->sanitizeStringList((array)($result['entity_hints'] ?? []), $this->maxEntityHints, $this->maxHintLength);
if (!empty($allowedEntityTypes)) {
$entityHints = array_values(array_intersect($allowedEntityTypes, $entityHints));
}
$fallbackReason = null;
if (isset($result['fallback_reason']) && $result['fallback_reason'] !== null) {
$fallbackReason = trim((string)$result['fallback_reason']);
if (mb_strlen($fallbackReason) > $this->maxFallbackReasonLength) {
$fallbackReason = mb_substr($fallbackReason, 0, $this->maxFallbackReasonLength);
}
}
return [
'success' => (bool)($result['success'] ?? false),
'normalized_query' => $normalizedQuery,
'aliases' => $aliases,
'entity_hints' => $entityHints,
'confidence' => max(0.0, min(1.0, (float)($result['confidence'] ?? 0.0))),
'association_hint' => (bool)($result['association_hint'] ?? false),
'fallback_reason' => $fallbackReason,
'source' => (string)($result['source'] ?? 'openai'),
];
}
private function sanitizeStringList(array $values, int $maxItems, int $maxLength): array
{
$result = [];
foreach ($values as $value) {
if (!is_string($value)) {
continue;
}
$item = trim(mb_strtolower($value));
if ($item === '') {
continue;
}
if (mb_strlen($item) > $maxLength) {
$item = mb_substr($item, 0, $maxLength);
}
if (!in_array($item, $result, true)) {
$result[] = $item;
}
if (count($result) >= $maxItems) {
break;
}
}
return $result;
}
private function failed(string $reason, string $source): array
{
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => $reason,
'source' => $source,
];
}
}
@@ -2,18 +2,18 @@
namespace classes;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_service
{
private system_search_intent_parser_i $intentParser;
private int $lowConfidenceResultThreshold = 5;
private int $lowConfidenceTopScoreThreshold = 60;
private int $defaultEntityFetchLimit = 200;
private int $defaultMaxResults = 50;
private int $maxExpandedTerms = 24;
private int $maxTermLength = 64;
private int $recencyScoreTolerance = 12;
private int $minimumEffectiveScore = 45;
private int $minimumExplicitTypeScore = 20;
private int $associationSeedScoreThreshold = 80;
private array $rankingBoostByType = [
'invoices' => 35,
'orders' => 35,
@@ -35,9 +35,8 @@ class system_search_service
private array $tableColumnsCache = [];
private array $customerContextCache = [];
public function __construct(?system_search_intent_parser_i $intentParser = null)
public function __construct()
{
$this->intentParser = $intentParser ?? new system_search_openai_intent_parser();
try {
system_search_economic_customer_index::ensureTable();
system_search_document_index::ensureTable();
@@ -59,18 +58,13 @@ class system_search_service
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
$includeAssociations = (bool)($options['include_associations'] ?? true);
$debugIntent = (bool)($options['debug_intent'] ?? false);
$limit = (int)($options['limit'] ?? 50);
$offset = (int)($options['offset'] ?? 0);
if ($limit < 1) {
$limit = 50;
$maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
if ($maxResults < 1) {
$maxResults = $this->defaultMaxResults;
}
if ($limit > 200) {
$limit = 200;
}
if ($offset < 0) {
$offset = 0;
if ($maxResults > $this->defaultMaxResults) {
$maxResults = $this->defaultMaxResults;
}
$allTypes = $this->allEntityTypes();
@@ -79,11 +73,12 @@ class system_search_service
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
}
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
$terms = $this->buildExpandedTerms($this->tokenize($query));
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
$baseMeta = [
'query' => $query,
'limit' => $limit,
'offset' => $offset,
'max_results' => $maxResults,
'allowed_types' => $activeTypes,
'cache' => ['hit' => false],
];
@@ -94,7 +89,8 @@ class system_search_service
'grouped_results' => $this->groupResultsByType([]),
'meta' => [
...$baseMeta,
'total' => 0,
'returned' => 0,
'truncated' => false,
],
];
}
@@ -104,16 +100,14 @@ class system_search_service
'include' => $includeTypes,
'exclude' => $excludeTypes,
'active' => $activeTypes,
'limit' => $limit,
'offset' => $offset,
'max' => $maxResults,
'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
'v' => 12,
'v' => 13,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getQuery($queryCacheHash);
@@ -122,7 +116,6 @@ class system_search_service
return $cached;
}
$terms = $this->buildExpandedTerms($this->tokenize($query));
$entityBoost = [];
$initialResults = $this->executeLexicalSearch(
$activeTypes,
@@ -135,76 +128,23 @@ class system_search_service
$moduleConfigVisibility,
$allowedDepartmentIds
);
$intentAssociationHint = false;
$intentMeta = [
'invoked' => false,
'source' => 'none',
'status' => 'skipped',
'confidence' => 0.0,
'expanded_terms' => $terms,
'entity_hints' => [],
'fallback_reason' => null,
];
$shouldInvokeIntent = !empty($terms) && (
$this->shouldInvokeIntentParser($initialResults)
|| $this->queryLooksIntentDriven($query, $terms)
);
if ($shouldInvokeIntent) {
$intentMeta['invoked'] = true;
$taxonomy = $this->taxonomy($activeTypes);
$intent = $this->intentParser->parse($query, $activeTypes, $taxonomy);
$intentMeta['source'] = (string)($intent['source'] ?? 'none');
$intentMeta['confidence'] = (float)($intent['confidence'] ?? 0.0);
$intentMeta['fallback_reason'] = $intent['fallback_reason'] ?? null;
$intentMeta['entity_hints'] = (array)($intent['entity_hints'] ?? []);
$intentAssociationHint = (bool)($intent['association_hint'] ?? false);
if (!empty($intent['success'])) {
$intentMeta['status'] = 'ok';
$boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? [])));
foreach ($boostedTypes as $boostedType) {
$entityBoost[$boostedType] = 25;
}
$expandedTerms = $this->buildExpandedTerms([
...$terms,
...$this->tokenize((string)($intent['normalized_query'] ?? '')),
...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))),
...$this->hintAliasTerms($boostedTypes, $taxonomy),
]);
$intentMeta['expanded_terms'] = $expandedTerms;
$initialResults = $this->executeLexicalSearch(
$activeTypes,
$expandedTerms,
$entityBoost,
$ownOnlyTypes,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds
);
} else {
$intentMeta['status'] = 'fallback';
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
if ($includeAssociations) {
$customerNumbers = [];
foreach ($initialResults as $result) {
if (!isset($result['customer_number'])) {
$customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
if ($customerNumber === null || $customerNumber <= 0) {
continue;
}
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
continue;
}
$customerNumbers[] = (int)$result['customer_number'];
$customerNumbers[] = $customerNumber;
}
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
if (count($customerNumbers) > 15) {
$customerNumbers = array_slice($customerNumbers, 0, 15);
if (count($customerNumbers) > 5) {
$customerNumbers = array_slice($customerNumbers, 0, 5);
}
if (!empty($customerNumbers)) {
$associationTypes = array_values(array_intersect(
@@ -232,18 +172,21 @@ class system_search_service
}
$item['score'] = max((int)$item['score'], 35);
}
unset($item);
$associated = $this->filterRelevantResults($associated, $includeTypes);
$initialResults = $this->mergeResults($initialResults, $associated);
}
}
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$preferRecency = $this->shouldPreferRecencySort($query, $terms);
usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
$scoreA = (int)($a['score'] ?? 0);
$scoreB = (int)($b['score'] ?? 0);
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a);
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b);
$effectiveScoreA = $this->effectiveResultScore($a);
$effectiveScoreB = $this->effectiveResultScore($b);
$recencyA = $this->resultRecencyTimestamp($a);
$recencyB = $this->resultRecencyTimestamp($b);
$cancelledA = $this->isCancelledBookingResult($a);
@@ -272,20 +215,17 @@ class system_search_service
return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
});
$total = count($initialResults);
$paged = array_slice($initialResults, $offset, $limit);
$grouped = $this->groupResultsByType($paged);
$limited = array_slice($initialResults, 0, $maxResults);
$grouped = $this->groupResultsByType($limited);
$meta = [
...$baseMeta,
'total' => $total,
'returned' => count($limited),
'truncated' => count($initialResults) > count($limited),
];
if ($debugIntent) {
$meta['intent_parser'] = $intentMeta;
}
$payload = [
'results' => $paged,
'results' => $limited,
'grouped_results' => $grouped,
'meta' => $meta,
];
@@ -294,13 +234,127 @@ class system_search_service
return $payload;
}
protected function shouldInvokeIntentParser(array $results): bool
/**
* @param array<int, string> $activeTypes
* @param array<int, string> $includeTypes
* @param array<int, string> $terms
* @return array<int, string>
*/
private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array
{
if (count($results) < $this->lowConfidenceResultThreshold) {
return true;
if (empty($activeTypes) || !empty($includeTypes)) {
return $activeTypes;
}
$topScore = (int)($results[0]['score'] ?? 0);
return $topScore < $this->lowConfidenceTopScoreThreshold;
$selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes()));
foreach ($activeTypes as $entityType) {
if (in_array($entityType, $selected, true)) {
continue;
}
if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) {
$selected[] = $entityType;
}
}
return array_values(array_intersect($activeTypes, array_values(array_unique($selected))));
}
/**
* @return array<int, string>
*/
private function defaultSearchEntityTypes(): array
{
return [
'customers',
'users',
'employees',
'orders',
'order_bookings',
'bookings',
'bookings_new',
'invoices',
'vehicles',
'departments',
'products',
'objects',
];
}
/**
* @param array<int, string> $terms
*/
private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool
{
$normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' ';
if (trim($normalizedQuery) === '') {
return false;
}
$aliases = system_search_registry::taxonomyAliases()[$entityType] ?? [];
$human = str_replace('_', ' ', $entityType);
$aliases[] = $entityType;
$aliases[] = $human;
$aliases[] = rtrim($human, 's');
$aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== '')));
foreach ($aliases as $alias) {
$aliasTerms = $this->tokenize($alias);
if (empty($aliasTerms)) {
continue;
}
if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) {
return true;
}
if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
return true;
}
$aliasText = trim(mb_strtolower($alias));
if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) {
return true;
}
}
return false;
}
/**
* @param array<int, array<string, mixed>> $results
* @param array<int, string> $includeTypes
* @return array<int, array<string, mixed>>
*/
private function filterRelevantResults(array $results, array $includeTypes): array
{
$explicitTypes = array_flip($includeTypes);
$filtered = [];
foreach ($results as $result) {
$entityType = (string)($result['entity_type'] ?? '');
$score = (int)($result['score'] ?? 0);
if ($score <= 0) {
continue;
}
if (isset($explicitTypes[$entityType])) {
if ($score >= $this->minimumExplicitTypeScore) {
$filtered[] = $result;
}
continue;
}
if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) {
$filtered[] = $result;
}
}
return $filtered;
}
private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool
{
$entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
if (!in_array($entityType, ['customers', 'users'], true)) {
return false;
}
if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) {
return false;
}
return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold;
}
/**
@@ -347,20 +401,6 @@ class system_search_service
$allowedDepartmentIds,
$forcedCustomerNumbers
);
if (empty($rows)) {
$rows = $this->searchEntity(
$entityType,
$terms,
$boost,
$ownOnly,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
} else {
$rows = $this->searchEntity(
$entityType,
@@ -2647,6 +2687,11 @@ class system_search_service
return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
}
private function effectiveResultScore(array $result): int
{
return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result);
}
private function boolishTrue(mixed $value): bool
{
if (is_bool($value)) {
@@ -2706,7 +2751,7 @@ class system_search_service
return false;
}
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact intent.
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches.
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
@@ -2729,68 +2774,6 @@ class system_search_service
]);
}
/**
* @param array<int, string> $boostedTypes
* @param array<string, array<int, string>> $taxonomy
* @return array<int, string>
*/
private function hintAliasTerms(array $boostedTypes, array $taxonomy): array
{
$terms = [];
foreach ($boostedTypes as $type) {
$aliases = $taxonomy[$type] ?? [];
if (!is_array($aliases)) {
continue;
}
foreach ($aliases as $alias) {
if (!is_string($alias) || trim($alias) === '') {
continue;
}
$terms = [...$terms, ...$this->tokenize($alias)];
if (count($terms) >= 12) {
return array_slice(array_values(array_unique($terms)), 0, 12);
}
}
}
return array_slice(array_values(array_unique($terms)), 0, 12);
}
/**
* Detect natural-language style queries where intent parsing is valuable
* even when lexical score looks strong.
*
* @param array<int, string> $terms
*/
private function queryLooksIntentDriven(string $query, array $terms): bool
{
$normalized = trim(mb_strtolower($query));
if ($normalized === '' || count($terms) < 2) {
return false;
}
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
$hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1;
$hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1;
$hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1;
if ($hasStrongDomainLanguage && count($terms) >= 2) {
return true;
}
if ($hasIntentVerb && count($terms) >= 3) {
return true;
}
if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) {
return true;
}
return mb_strlen($normalized) >= 28 && count($terms) >= 4;
}
private function queryHasExplicitIdentifier(string $normalizedQuery): bool
{
if ($normalizedQuery === '') {
@@ -2985,22 +2968,6 @@ class system_search_service
return system_search_registry::allEntityTypes();
}
private function taxonomy(array $activeTypes): array
{
$aliases = system_search_registry::taxonomyAliases();
$taxonomy = [];
foreach ($activeTypes as $type) {
$resolved = $aliases[$type] ?? [];
if (empty($resolved)) {
$human = str_replace('_', ' ', $type);
$singular = rtrim($human, 's');
$resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== '')));
}
$taxonomy[$type] = $resolved;
}
return $taxonomy;
}
private function groupResultsByType(array $results): array
{
$grouped = [];
@@ -291,6 +291,12 @@ class xlvask_automation_service
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
}
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
{
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
}
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
{
$usageBag = self::productBagForAutomation($usageItems);
@@ -600,18 +606,52 @@ class xlvask_automation_service
if ($action === self::ACTION_ATTACH) {
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE;
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
}
if ($action === self::ACTION_CREATE) {
return $xlvask->config->automatic_order_creation_enabled->isTrue()
&& $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS
&& $confidence >= self::AUTO_CREATE_CONFIDENCE;
return false;
}
return false;
}
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
{
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
return false;
}
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
return false;
}
$usageItems = $context['items'] ?? [];
$orderItems = $candidateOrder['order_items'] ?? [];
return is_array($usageItems)
&& is_array($orderItems)
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
}
private function candidateOrderFromSuggestion(array $suggestion): ?array
{
$candidateOrder = $suggestion['candidate_order'] ?? null;
if (is_array($candidateOrder)) {
return $candidateOrder;
}
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
return null;
}
$decoded = json_decode($candidateOrderJson, true);
return is_array($decoded) ? $decoded : null;
}
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
{
try {
@@ -646,7 +686,7 @@ class xlvask_automation_service
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
if ($automatic) {
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.');
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
}
return $this->formatSuggestion($latest);
@@ -660,6 +700,15 @@ class xlvask_automation_service
}
}
private function automaticFeedbackReason(array $suggestion, array $context): string
{
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
return 'Automatisk accepteret: Prisoverensstemmelse.';
}
return 'Automatisk accepteret.';
}
private function createOrderFromContext(array $context): orders_o
{
$orderData = $context['proposed_order'];
+5 -2
View File
@@ -39,8 +39,11 @@ $htaccess = "
# php -- END cPanel-generated handler, do not edit
";
// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.)
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
// Keep the compatibility rewrite file stable without rewriting it every minute.
$htaccessPath = __DIR__ . '/.htaccess';
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
file_put_contents($htaccessPath, $htaccess);
}
// Log the time of the cron job
file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
+293 -13
View File
@@ -8,6 +8,7 @@ use classes\invoice_period_flag_service;
use classes\coolify_manager;
use classes\replication_manager;
use classes\redis;
use classes\selfserve;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
@@ -40,6 +41,11 @@ const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
require_once __DIR__ . '/../classes/cron_schedule.php';
require_once __DIR__ . '/../classes/cron_task_definition.php';
require_once __DIR__ . '/../classes/cron_task_registry.php';
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
require_once __DIR__ . '/../classes/cron_scheduler.php';
if (!defined('WD')) {
exit;
@@ -652,7 +658,6 @@ function SystemSearchCacheMaintenanceCron(): void
if ($rebuildRequest !== null) {
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$scope = (string)($rebuildRequest['scope'] ?? 'all');
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
if ($scope === 'types' && !empty($types)) {
@@ -1470,17 +1475,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
}
}
foreach ( $cron_tasks as $task => $data ) {
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
$nextRun = $lastRun + $data['interval'];
$cron_tasks[$task]['last_run'] = $lastRun;
$cron_tasks[$task]['next_run'] = $nextRun;
if ($nextRun <= time()) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
$data['function']();
redis->set_last_crond_run($task, time());
echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n";
} else {
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
function SelfserveOpeningRelayActivationCron(): array
{
global $db;
if (!($db instanceof \classes\db)) {
warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
return [
'skipped' => true,
'reason' => 'database_unavailable',
];
}
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'));
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
$summary = [
'checked_departments' => count($candidates),
'activated_departments' => 0,
'skipped_departments' => 0,
'failed_departments' => 0,
'activated_relays' => 0,
'skipped_relays' => 0,
'failed_relays' => 0,
];
foreach ($candidates as $candidate) {
$departmentId = (int)($candidate['department_id'] ?? 0);
$opensAt = (string)($candidate['opens_at'] ?? '');
if ($departmentId <= 0 || $opensAt === '') {
$summary['skipped_departments']++;
continue;
}
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
$summary['skipped_departments']++;
continue;
}
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
$summary['activated_relays'] += (int)$departmentSummary['activated'];
$summary['skipped_relays'] += (int)$departmentSummary['skipped'];
$summary['failed_relays'] += (int)$departmentSummary['failed'];
if ((int)$departmentSummary['failed'] > 0) {
$summary['failed_departments']++;
continue;
}
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
if ((int)$departmentSummary['activated'] > 0) {
$summary['activated_departments']++;
} else {
$summary['skipped_departments']++;
}
}
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
. $summary['activated_relays'] . " cleaner relays activated across "
. $summary['activated_departments'] . " departments.\n";
return $summary;
}
function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array
{
global $db;
$weekday = strtolower($now->format('l'));
$allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
if (!in_array($weekday, $allowedWeekdays, true)) {
return [];
}
$startColumn = $weekday . '_start';
$endColumn = $weekday . '_end';
$sql = "
SELECT
oh.department AS department_id,
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
FROM department_time_bookings_opening_hours oh
INNER JOIN department_variables dv ON dv.department_id = oh.department
INNER JOIN department_lanes dl ON dl.department = oh.department
WHERE dv.variable = 'selfserve_enabled'
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
AND oh.`$startColumn` IS NOT NULL
AND oh.`$endColumn` IS NOT NULL
AND dl.deleted_at IS NULL
AND COALESCE(dl.selfserve_enabled, 1) = 1
AND dl.relay_machine_cleaner_id IS NOT NULL
AND TRIM(dl.relay_machine_cleaner_id) <> ''
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
return array_values(array_filter(
$db->fetch_all($result),
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
$now,
(string)($row['opens_at'] ?? ''),
(string)($row['closes_at'] ?? '')
)
));
}
function selfserveOpeningCleanerRelayWindowActive(
DateTimeImmutable $now,
?string $opensAt,
?string $closesAt
): bool {
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) {
return false;
}
$nowSeconds = ((int)$now->format('G') * 3600)
+ ((int)$now->format('i') * 60)
+ (int)$now->format('s');
if ($opensAtSeconds < $closesAtSeconds) {
return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds;
}
return $nowSeconds >= $opensAtSeconds;
}
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
{
$time = trim((string)$time);
if ($time === '') {
return null;
}
if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
return null;
}
$hours = (int)$matches[1];
$minutes = (int)$matches[2];
$seconds = isset($matches[3]) ? (int)$matches[3] : 0;
if ($hours > 23 || $minutes > 59 || $seconds > 59) {
return null;
}
return ($hours * 3600) + ($minutes * 60) + $seconds;
}
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array
{
$summary = [
'activated' => 0,
'skipped' => 0,
'failed' => 0,
];
$selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
foreach ($lanes as $departmentLane) {
if (!($departmentLane instanceof department_lanes_o)) {
$summary['skipped']++;
continue;
}
try {
if (!$departmentLane->isSelfServeEnabled()) {
$summary['skipped']++;
continue;
}
} catch (Throwable) {
$summary['skipped']++;
continue;
}
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
$summary['skipped']++;
continue;
}
$laneId = (int)$departmentLane->id;
if ($laneId <= 0) {
$summary['skipped']++;
continue;
}
try {
$lane = $selfserve->lane($laneId);
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
$summary['skipped']++;
continue;
}
$lane->setMachineCleanerRelayStatusHard(true);
$summary['activated']++;
} catch (Throwable $throwable) {
$summary['failed']++;
warn(
'SelfserveOpeningRelayActivationCron failed for department '
. $departmentId
. ', lane '
. $laneId
. ': '
. $throwable->getMessage()
);
}
}
return $summary;
}
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
{
if (
$departmentLane === null
|| !isset($departmentLane->relay_machine_cleaner_id)
|| !is_object($departmentLane->relay_machine_cleaner_id)
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
) {
return false;
}
try {
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
} catch (Throwable) {
return false;
}
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
}
function selfserveOpeningCleanerRelayActivationKey(
int $departmentId,
string $opensAt,
DateTimeImmutable $now
): string {
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
return 'selfserve:opening-cleaner-relays:'
. $now->format('Y-m-d')
. ':'
. $departmentId
. ':'
. $normalizedOpensAt;
}
function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool
{
if (!defined('redis')) {
return false;
}
try {
return redis->exists($cacheKey);
} catch (Throwable) {
return false;
}
}
function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void
{
if (!defined('redis')) {
return;
}
try {
redis->setEx($cacheKey, '1', 36 * 3600);
} catch (Throwable) {
// Redis idempotency should not block relay activation.
}
}
if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) {
return;
}
try {
$response_cron = (new \classes\cron_scheduler())->runDue('automatic');
} catch (Throwable $throwable) {
warn('Cron scheduler failed: ' . $throwable->getMessage());
$response_cron = [
'ran' => [],
'count' => 0,
'error' => $throwable->getMessage(),
];
}
@@ -1,26 +0,0 @@
<?php
namespace interfaces;
interface system_search_intent_parser_i
{
/**
* Parse a natural-language search query into structured search hints.
*
* @param string $query The raw user query.
* @param array $allowedEntityTypes Entity types the caller is allowed to search.
* @param array $taxonomy Public taxonomy/aliases to improve intent parsing.
* @return array{
* success: bool,
* normalized_query: string,
* aliases: array<int, string>,
* entity_hints: array<int, string>,
* confidence: float,
* association_hint: bool,
* fallback_reason: string|null,
* source: string
* }
*/
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array;
}
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'backups.create_backup',
'legacy_name' => 'backup',
'name' => 'Create backup',
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
'module' => 'backups',
'handler' => 'backup',
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 60000,
'priority' => 115,
],
];
@@ -131,7 +131,7 @@
"post": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate",
"description": "Requires permission `download_own_wash_certificate`. Returns a presigned download link if certificate exists and user has access.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Returns a presigned download link if certificate exists and user has access.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -146,7 +146,7 @@
"get": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate PDF",
"description": "Requires permission `download_own_wash_certificate`. Checks both legacy and current storage buckets.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Checks both legacy and current storage buckets.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -0,0 +1,28 @@
<?php
return [
[
'id' => 'coolify.availability_monitor',
'legacy_name' => 'CoolifyAvailabilityMonitorCron',
'name' => 'Coolify availability monitor',
'description' => 'Checks registered Coolify-managed infrastructure targets.',
'module' => 'coolify',
'handler' => 'CoolifyAvailabilityMonitorCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 2000,
'priority' => 25,
],
[
'id' => 'coolify.load_balancer_reconcile',
'legacy_name' => 'CoolifyLoadBalancerReconcileCron',
'name' => 'Coolify load balancer reconcile',
'description' => 'Reconciles public gateway load balancer targets when automation is enabled.',
'module' => 'coolify',
'handler' => 'CoolifyLoadBalancerReconcileCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 26,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'dynamicimages.pre_render',
'legacy_name' => 'PreRenderDynamicImagesCron',
'name' => 'Pre-render dynamic images',
'description' => 'Pre-renders common self-serve dynamic image variants into Redis.',
'module' => 'dynamicimages',
'handler' => 'PreRenderDynamicImagesCron',
'schedule' => ['type' => 'interval', 'seconds' => 900],
'timeout_seconds' => 900,
'estimated_duration_ms' => 30000,
'priority' => 90,
],
];
@@ -0,0 +1,64 @@
<?php
return [
[
'id' => 'economic.sync_user_customer_discounts',
'legacy_name' => 'SyncUserEconomicCustomerDiscounts',
'name' => 'Sync e-conomic customer discounts',
'description' => 'Clears cached customer discounts so e-conomic discount data can refresh.',
'module' => 'economic',
'handler' => 'SyncUserEconomicCustomerDiscounts',
'schedule' => ['type' => 'interval', 'seconds' => 180],
'timeout_seconds' => 300,
'estimated_duration_ms' => 2000,
'priority' => 30,
],
[
'id' => 'economic.sync_user_customer_details',
'legacy_name' => 'SyncUserEconomicCustomerDetails',
'name' => 'Sync e-conomic customer details',
'description' => 'Refreshes customer details and search documents from e-conomic.',
'module' => 'economic',
'handler' => 'SyncUserEconomicCustomerDetails',
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 30000,
'priority' => 110,
],
[
'id' => 'economic.sync_system_search_customer_index',
'legacy_name' => 'SyncSystemSearchEconomicCustomerIndex',
'name' => 'Sync e-conomic customer search index',
'description' => 'Refreshes the system search e-conomic customer index.',
'module' => 'economic',
'handler' => 'SyncSystemSearchEconomicCustomerIndex',
'schedule' => ['type' => 'interval', 'seconds' => 900],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 75,
],
[
'id' => 'economic.sync_invoice_status',
'legacy_name' => 'SyncEconomicInvoiceStatus',
'name' => 'Sync e-conomic invoice status',
'description' => 'Checks e-conomic invoice errors and draft status.',
'module' => 'economic',
'handler' => 'SyncEconomicInvoiceStatus',
'schedule' => ['type' => 'interval', 'seconds' => 120],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 35,
],
[
'id' => 'economic.transfer_queue',
'legacy_name' => 'EconomicTransferQueueCron',
'name' => 'Process e-conomic transfer queue',
'description' => 'Processes pending e-conomic transfer queue jobs in bounded batches.',
'module' => 'economic',
'handler' => 'EconomicTransferQueueCron',
'schedule' => ['type' => 'interval', 'seconds' => 30],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 20,
],
];
@@ -799,7 +799,6 @@ class edge_gateway_manager
$existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'relay_id' => $relayId,
'deleted_at' => null,
], ['id']);
if ($existing !== []) {
@@ -818,6 +817,7 @@ class edge_gateway_manager
$bindingObject->approved_by->set($userId);
$bindingObject->approved_at->set($this->now());
$bindingObject->metadata_json->set($bindingMetadata);
$bindingObject->deleted_at->set(null);
continue;
}
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'failover.replica_monitor',
'legacy_name' => 'ReplicaFailoverMonitorCron',
'name' => 'Replica failover monitor',
'description' => 'Checks replicated services and promotes eligible replicas during automatic failover.',
'module' => 'failover',
'handler' => 'ReplicaFailoverMonitorCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 24,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'goals.progress_alerts',
'legacy_name' => 'GoalsProgressAlertsCron',
'name' => 'Goals progress alerts',
'description' => 'Evaluates department goal alert schedules and sends due progress alerts.',
'module' => 'goals',
'handler' => 'GoalsProgressAlertsCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 600,
'estimated_duration_ms' => 10000,
'priority' => 45,
],
];
@@ -440,6 +440,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return null;
}
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
if (!$session->markCompletedIfOpen($orderId)) {
return $this->getSessionSummary((int)$session->id);
}
@@ -456,6 +458,25 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id);
}
protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void
{
try {
if ($session->wash_started_at->value() !== null) {
return;
}
$lane = (new selfserve())->lane($laneId);
$washStartedAt = (int)$lane->getWashStartTime();
if ($washStartedAt <= 0) {
return;
}
$session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt));
} catch (\Throwable) {
// Session timestamp enrichment must not block STOP completion.
}
}
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
{
$lane = (new selfserve())->lane($laneId);
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'selfserve.activate_opening_cleaner_relays',
'legacy_name' => 'SelfserveOpeningRelayActivationCron',
'name' => 'Activate self-serve opening cleaner relays',
'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.',
'module' => 'selfserve',
'handler' => 'SelfserveOpeningRelayActivationCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 45,
],
];
@@ -0,0 +1,64 @@
<?php
return [
[
'id' => 'system.sync_logs',
'legacy_name' => 'SyncLogs',
'name' => 'Sync logs',
'description' => 'Flush Redis-backed application logs into the database.',
'module' => 'system',
'handler' => 'syncLogsToDatabase',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 120,
'estimated_duration_ms' => 1000,
'priority' => 10,
],
[
'id' => 'system.search_cache_maintenance',
'legacy_name' => 'SystemSearchCacheMaintenanceCron',
'name' => 'System search cache maintenance',
'description' => 'Processes dirty-table and full rebuild requests for system search caches.',
'module' => 'system',
'handler' => 'SystemSearchCacheMaintenanceCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 600,
'estimated_duration_ms' => 3000,
'priority' => 70,
],
[
'id' => 'system.prune_session_activity',
'legacy_name' => 'PruneSystemSessionActivityCron',
'name' => 'Prune system session activity',
'description' => 'Deletes stale system session activity rows.',
'module' => 'system',
'handler' => 'PruneSystemSessionActivityCron',
'schedule' => ['type' => 'interval', 'seconds' => 86400],
'timeout_seconds' => 300,
'estimated_duration_ms' => 1000,
'priority' => 120,
],
[
'id' => 'system.invoice_flags_manual_cache',
'legacy_name' => 'WarmInvoicePeriodManualFlagsCron',
'name' => 'Warm manual invoice flag cache',
'description' => 'Warms superuser invoice-period manual flag counters.',
'module' => 'system',
'handler' => 'WarmInvoicePeriodManualFlagsCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 600,
'estimated_duration_ms' => 2000,
'priority' => 80,
],
[
'id' => 'system.invoice_flags_automatic_cache',
'legacy_name' => 'WarmInvoicePeriodAutomaticFlagsCron',
'name' => 'Warm automatic invoice flag cache',
'description' => 'Warms current, previous, and queued invoice-period automatic flag caches.',
'module' => 'system',
'handler' => 'WarmInvoicePeriodAutomaticFlagsCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 900,
'estimated_duration_ms' => 5000,
'priority' => 85,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'weatherapi.preload_department_responses',
'legacy_name' => 'PreloadDepartmentWeatherResponsesCron',
'name' => 'Preload department weather responses',
'description' => 'Warms department weather timeline responses for visible and active departments.',
'module' => 'weatherapi',
'handler' => 'PreloadDepartmentWeatherResponsesCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 40,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'workfeed.warm_employee_names',
'legacy_name' => 'WarmWorkfeedEmployeeNamesCron',
'name' => 'Warm Workfeed employee names',
'description' => 'Warms Redis mappings for Workfeed employee identifiers and display names.',
'module' => 'workfeed',
'handler' => 'WarmWorkfeedEmployeeNamesCron',
'schedule' => ['type' => 'interval', 'seconds' => 21600],
'timeout_seconds' => 600,
'estimated_duration_ms' => 5000,
'priority' => 100,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'xlvask.sync_module',
'legacy_name' => 'SyncXLVaskModuleCron',
'name' => 'Sync XL Vask module',
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
'module' => 'xlvask',
'handler' => 'SyncXLVaskModuleCron',
'schedule' => ['type' => 'interval', 'seconds' => 3600],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 95,
],
];
@@ -490,18 +490,19 @@ class customer_vehicles_o extends db
return [];
}
//print_r($transaction_ids);
// Convert the array of transaction ids to a comma separated string
$orders = '';
$orders = [];
foreach ($transaction_ids as $transaction_id) {
// Check if the transaction is included in the invoicing.
$tmp = (new orders_o())->select((int)$transaction_id);
if (!$tmp->isIncludedInInvoicing()) {
continue; // The transaction is not included in the invoicing, skip it
}
$orders .= (int)$transaction_id . ',';
$orders[] = (int)$transaction_id;
}
// Remove the last comma
$orders = rtrim($orders, ',');
if (empty($orders)) {
return [];
}
$order_ids = implode(',', $orders);
// Get the first two transactions that are not deleted and contains at least one order item with the 'product_id' of the vehicle type for the vehicle
$query = "
SELECT o.id
@@ -509,7 +510,7 @@ class customer_vehicles_o extends db
JOIN order_items oi ON oi.order_id = o.id
WHERE o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND o.id IN ($orders)
AND o.id IN ($order_ids)
AND oi.product_id = " . (int)$this->type->value() . "
GROUP BY o.id
ORDER BY o.created_at ASC
@@ -0,0 +1,114 @@
<?php
namespace objects;
use classes\db;
use classes\department_customer_price_overrides_schema_bootstrap;
use traits\db_object_t;
class department_customer_price_overrides_o extends db
{
use db_object_t;
public function structure(): void
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->setTable('department_customer_price_overrides');
}
public function objectChanged(): void
{
}
public function setPrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId, int $percentage, ?int $fixedPrice = null): void
{
global $db;
if ($isCategory) {
$fixedPrice = null;
}
$this->removePrice($departmentId, $userId, $isCategory, $objectId);
if ($percentage <= 0 && $fixedPrice === null) {
return;
}
$objectId = $db->escape_string((string)$objectId);
$fixedPriceSql = $fixedPrice === null ? 'NULL' : (string)max(0, (int)$fixedPrice);
$sql = "INSERT INTO {$this->table} (`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
VALUES (" . (int)$departmentId . ", " . (int)$userId . ", " . (int)$isCategory . ", '{$objectId}', " . (int)$percentage . ", {$fixedPriceSql})";
$db->query($sql);
}
public function removePrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId): void
{
global $db;
$objectId = $db->escape_string((string)$objectId);
$sql = "DELETE FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
AND `is_category` = " . (int)$isCategory . "
AND `product_or_category_id` = '{$objectId}'";
$db->query($sql);
}
/**
* @return array<int, array<string, mixed>>
*/
public function getAllPrices(int $departmentId, int $userId): array
{
global $db;
$sql = "SELECT * FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
ORDER BY `is_category` DESC, `product_or_category_id` ASC";
$result = $db->query($sql);
$prices = [];
if ($result && $result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$prices[] = $this->parseRow($row);
}
}
return $prices;
}
public function getDirectPriceRow(int $departmentId, int $userId, bool $isCategory, int|string $objectId): ?array
{
global $db;
$objectId = $db->escape_string((string)$objectId);
$sql = "SELECT * FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
AND `is_category` = " . (int)$isCategory . "
AND `product_or_category_id` = '{$objectId}'
LIMIT 1";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
return $this->parseRow($result->fetch_assoc());
}
private function parseRow(array $row): array
{
$isCategory = (bool)$row['is_category'];
return [
'id' => (int)$row['id'],
'department_id' => (int)$row['department_id'],
'user_id' => (int)$row['user_id'],
'is_category' => $isCategory,
'product_or_category_id' => $isCategory ? (string)$row['product_or_category_id'] : (int)$row['product_or_category_id'],
'percentage' => (int)$row['percentage'],
'fixed_price' => array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null,
'created_at' => (string)$row['created_at'],
'updated_at' => (string)$row['updated_at'],
];
}
}
@@ -2,7 +2,10 @@
namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use classes\db;
use classes\department_wash_count_service;
use classes\object_property;
use Exception;
use traits\db_object_t;
@@ -510,8 +513,6 @@ class department_daily_reports_o extends db
public function getTransactionsOnDateWashesCount(string $date, int $department_id, string $date_to = null): int
{
return self::methodCacheWithParameters(__METHOD__, func_get_args(), 120, function() use ($date, $department_id, $date_to) {
global /** @var db $db */
$db;
// If the date_to is null, set it to the date
if ($date_to === null) {
$date_to = $date; // Making the report for an entire day
@@ -519,33 +520,7 @@ class department_daily_reports_o extends db
// Set the date time to cover the entire day
$date = date('Y-m-d 00:00:00', strtotime($date));
$date_to = date('Y-m-d 23:59:59', strtotime($date_to));
$conn = $db->conn();
// Get all the product prices, in all the orders (Not counting removed orders), for the given date and department, and sum them
$stmt = $conn->prepare(
'SELECT COUNT(DISTINCT o.id) as amount FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.department_id = ? AND DATE(o.created_at) BETWEEN ? AND ? AND o.deleted_at IS NULL AND oi.deleted_at IS NULL AND p.is_wash = 1'
);
if ($stmt) {
$stmt->bind_param('iss', $department_id, $date, $date_to); // Bind parameters (i = integer, s = string)
$stmt->execute();
$result = $stmt->get_result(); // Get the result set from the statement
$data = $result->fetch_assoc(); // Fetch the result as an associative array
// Access the "amount" field
if (!$data) {
// If there are no orders, set the amount to 0
$amount = 0;
} else {
$amount = $data['amount'];
}
$stmt->close(); // Close the statement
} else {
// Handle query preparation error
die('Query preparation failed: ' . $conn->error);
}
return (int)$amount;
return (new department_wash_count_service())->countInDateRange($date, $date_to, $department_id);
});
}
@@ -616,9 +591,6 @@ class department_daily_reports_o extends db
*/
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 [
@@ -631,30 +603,17 @@ class department_daily_reports_o extends db
}
[$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;
$transaction_summary = (new department_wash_count_service())->transactionSummary(
$date_start,
$date_end,
$normalized_department_ids
);
return [
'quantity' => (int)($row['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
'washes' => (int)($row['washes'] ?? 0),
'quantity' => (int)($transaction_summary['quantity'] ?? 0),
'products' => (int)($transaction_summary['products'] ?? 0),
'earnings' => (int)($transaction_summary['earnings'] ?? 0),
'washes' => (int)($transaction_summary['washes'] ?? 0),
'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to),
];
}
@@ -758,45 +717,13 @@ class department_daily_reports_o extends db
*/
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;
return (new department_wash_count_service())->listTransactions($date_start, $date_end, $normalized_department_ids);
}
/**
@@ -381,7 +381,7 @@ class order_bookings_o extends db
}
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
if ($order->hasWashCertificateAttached()) {
if ($this->getOrder()->hasWashCertificateAttached()) {
$this->sendWashCertificateToCustomer();
}
}
@@ -585,7 +585,7 @@ class order_bookings_o extends db
return !empty($this->order_id->value());
}
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, string $date = null): int
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, ?string $date = null): int
{
global /** @var db $db */
$db;
+1 -1
View File
@@ -178,7 +178,7 @@ class order_items_o extends db
// Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id);
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false, (int)$order->department_id->value());
}
// If the price is forced, set the price to the forced price
+8 -82
View File
@@ -2,8 +2,11 @@
namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use attachments\helpers\attachment_content;
use classes\db;
use classes\department_wash_count_service;
use classes\email;
use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap;
@@ -1436,7 +1439,7 @@ class orders_o extends db
throw new Exception('No user found matching the customer number in the usage log');
}
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price);
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value());
}
$order_item->notes->set(null); // Set notes for the simulated order item
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
@@ -1509,7 +1512,7 @@ class orders_o extends db
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price;
}
return $current_user->applyProductCustomerPricing((int)$product->id, $price);
return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value());
}
/**
@@ -1598,7 +1601,7 @@ class orders_o extends db
}
$unitPrice = (int)$department_price_cache[$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false);
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id);
}
$post_discount = $unitPrice * $quantity;
$total += $post_discount;
@@ -2005,33 +2008,7 @@ class orders_o extends db
public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int
{
global /** @var db $db */
$db;
// Validate the date range
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
// Prepare the SQL query to count washes in the date range for the department
$date_start = $db->escape_string($date_start);
$date_end = $db->escape_string($date_end);
// Get the amount of orders with at least one order item that has a product with the is_wash column set to true
$sql = "SELECT COUNT(DISTINCT o.id) AS wash_count
FROM $this->table o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id = $department_id
AND o.created_at BETWEEN '$date_start' AND '$date_end'
AND o.deleted_at IS NULL
AND p.is_wash = 1";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return 0; // No washes found in the date range
}
$row = $result->fetch_assoc();
return (int)$row['wash_count'];
return (new department_wash_count_service())->countInDateRange($date_start, $date_end, $department_id);
}
/**
@@ -2041,58 +2018,7 @@ class orders_o extends db
*/
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;
return (new department_wash_count_service())->countByHourForDepartments($date_start, $date_end, $department_ids);
}
/**
+4 -4
View File
@@ -326,13 +326,13 @@ class products_o extends db
return $this->getDepartmentPriceResolution($department_id)['price'];
}
public function applyCustomerDiscounts(array $products, users_o $customer): array
public function applyCustomerDiscounts(array $products, users_o $customer, ?int $departmentId = null): array
{
global $db;
// Get the customer's discounts
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer), $products);
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer, $departmentId), $products);
}
public function applyCustomerDiscount(array $product, users_o $customer): array
public function applyCustomerDiscount(array $product, users_o $customer, ?int $departmentId = null): array
{
global $db;
// Validate input
@@ -340,7 +340,7 @@ class products_o extends db
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
}
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price'], true, $departmentId);
}
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
+59 -7
View File
@@ -936,9 +936,18 @@ class users_o extends db
* @param bool $is_category If the object is a category
* @return int|null The discount percentage
*/
public function getCustomPrice(int $object_id, bool $is_category = false): int|null
public function getCustomPrice(int $object_id, bool $is_category = false, ?int $department_id = null): int|null
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
$row = (new department_customer_price_overrides_o())->getDirectPriceRow(
(int)$department_id,
(int)$this->id,
$is_category,
$object_id
);
return $row === null ? 0 : (int)$row['percentage'];
}
// Get the custom price for the product
$discount = $this->price_overrides->setUser($this->id)->getPrice($is_category, $object_id);
// If the is_category is false, check if there is a custom price for the category that the product belongs to
@@ -962,9 +971,12 @@ class users_o extends db
* - Category discount (If the product allows category inheritance of discounts)
* - Product discount (If the product has a custom price)
*/
public function getProductDiscountPercentage(int $product_id): int|null
public function getProductDiscountPercentage(int $product_id, ?int $department_id = null): int|null
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
return $this->getDepartmentScopedProductDiscountPercentage($product_id, (int)$department_id);
}
// Get the product by ID
$product = (new products_o())->select((int)$product_id);
$doesProductAllowCategoryDiscount = (bool)$product->apply_category_discount->value();
@@ -981,24 +993,36 @@ class users_o extends db
return $discount_percentage === null ? 0 : (int)$discount_percentage;
}
public function getProductFixedPrice(int $product_id): ?int
public function getProductFixedPrice(int $product_id, ?int $department_id = null): ?int
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
$row = (new department_customer_price_overrides_o())->getDirectPriceRow(
(int)$department_id,
(int)$this->id,
false,
(int)$product_id
);
if ($row === null || $row['fixed_price'] === null) {
return null;
}
return (int)$row['fixed_price'];
}
return $this->price_overrides->setUser($this->id)->getFixedPrice(false, $product_id);
}
public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true): int
public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true, ?int $department_id = null): int
{
self::requireSelected();
$fixed_price = $this->getProductFixedPrice($product_id);
$fixed_price = $this->getProductFixedPrice($product_id, $department_id);
if ($fixed_price !== null) {
return $fixed_price;
}
$discount_percentage = $use_final_price_discount_calculation
? (int)$this->getProductDiscountPercentage($product_id)
: (int)$this->getCustomPrice($product_id, false);
? (int)$this->getProductDiscountPercentage($product_id, $department_id)
: (int)$this->getCustomPrice($product_id, false, $department_id);
if ($discount_percentage <= 0) {
return $base_price;
}
@@ -1006,6 +1030,34 @@ class users_o extends db
return (int)round($base_price * (1 - ($discount_percentage / 100)));
}
private function getDepartmentScopedProductDiscountPercentage(int $product_id, int $department_id): int
{
self::requireSelected();
$overrides = new department_customer_price_overrides_o();
$product = (new products_o())->select((int)$product_id);
$discounts = [];
$productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id);
if ($productRow !== null) {
$discounts[] = (int)$productRow['percentage'];
}
if ((bool)$product->apply_category_discount->value()) {
$categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value());
if ($categoryRow !== null) {
$discounts[] = (int)$categoryRow['percentage'];
}
$globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global');
if ($globalRow !== null) {
$discounts[] = (int)$globalRow['percentage'];
}
}
return max([0, ...$discounts]);
}
/**
File diff suppressed because it is too large Load Diff
@@ -1735,7 +1735,7 @@ class InvoicingPeriodRoute
if (!array_key_exists($product_id, $discount_cache)) {
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false);
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false, $department_id);
}
$discount_cache[$product_id] = $unit_price;
}
+17 -8
View File
@@ -291,12 +291,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
// Check if the required fields are set
@@ -340,7 +339,7 @@ class bookingsRoute
);
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -348,12 +347,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
self::requireParameters(['id']);
@@ -401,7 +399,7 @@ class bookingsRoute
}
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -530,4 +528,15 @@ class bookingsRoute
]
);
}
private function requireOwnWashCertificateDownloadAccess(): void
{
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false && $user->exists() && $this->hasPermission('user')) {
return;
}
$this->requirePermission('download_own_wash_certificate');
}
}
+141 -26
View File
@@ -3,7 +3,9 @@
namespace routes;
use classes\authentication;
use classes\cron_scheduler;
use objects\logs_o;
use Throwable;
use traits\route_t;
class cronRoute
@@ -12,39 +14,152 @@ class cronRoute
public function run(): void
{
$this->post('/superuser/cron', function () {
// Get the post data
$this->get('/superuser/cron', function () {
global $response;
// Make sure the user has the SUPERUSER_RUN_CRON permission
$this->requirePermission('SUPERUSER_RUN_CRON');
// Get the user object
$user = (new authentication())->get_user();
// Get the post data
$data = json_decode(file_get_contents('php://input'), true);
// Check if a specific cron job is requested
if (isset($data['job'])) {
// Check if the cron job exists
if (!file_exists(WD . '/cron/' . $data['job'] . '.php')) {
$response->error('Cron job not found', 404);
}
// Include the cron job
require_once WD . '/cron/' . $data['job'] . '.php';
// Log the incident
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_JOB_RUN', 'Ran cron job: ' . $data['job']);
$response->success('Cron job ran successfully');
}
// If no specific cron job is requested, run all cron jobs (through the cron.php file)
require_once WD . '/cron/Cron.php';
// Log the incident
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_RUN', 'Ran all cron jobs');
$this->requireClassicSuperuserPermission('superuser_cron_view');
$response->success((new cron_scheduler())->listTasks());
}, [
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
]);
$this->get('/superuser/cron/runs', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
$task_id = $this->getParameter('task_id');
$limit = (int)($this->getParameter('limit') ?? 50);
$response->success([
'message' => 'All cron jobs ran successfully',
'data' => $response_cron ?? []
'runs' => (new cron_scheduler())->listRuns(is_string($task_id) ? $task_id : null, $limit),
]);
}, [
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
]);
$this->post('/superuser/cron/run', function () {
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
$parameters = $this->getParametersAsArray();
$task_id = trim((string)($parameters['task_id'] ?? ''));
if ($task_id === '') {
$response->error('Missing cron task id.', 400);
}
try {
$run = (new cron_scheduler())->runTask(
$task_id,
'manual',
$this->actorUserId(),
$this->toBool($parameters['force'] ?? false, false)
);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron task: ' . $task_id);
$response->success($run);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'SUPERUSER_RUN_CRON' => 'Run cron jobs',
]);
$this->patch('/superuser/cron/config', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
$parameters = $this->getParametersAsArray();
$task_id = trim((string)($parameters['task_id'] ?? ''));
if ($task_id === '') {
$response->error('Missing cron task id.', 400);
}
try {
$config = [];
if (array_key_exists('enabled', $parameters)) {
$config['enabled'] = $this->toBool($parameters['enabled'], true);
}
if (array_key_exists('schedule', $parameters)) {
$config['schedule'] = $parameters['schedule'];
}
$result = (new cron_scheduler())->updateTaskConfig($task_id, $config);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_TASK_CONFIG_UPDATED', 'Updated cron task: ' . $task_id);
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_cron_manage' => 'Configure cron task schedules and enabled state',
]);
$this->post('/superuser/cron', function () {
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
$parameters = $this->getParametersAsArray();
$scheduler = new cron_scheduler();
try {
if (isset($parameters['job']) && trim((string)$parameters['job']) !== '') {
$job = trim((string)$parameters['job']);
$run = $scheduler->runTask($job, 'manual', $this->actorUserId(), true);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron job: ' . $job);
$response->success([
'message' => 'Cron job ran successfully',
'run' => $run,
]);
}
$result = $scheduler->runDue('manual');
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_RUN', 'Ran due cron jobs');
$response->success([
'message' => 'Due cron jobs ran successfully',
'data' => $result,
]);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
},
[
'SUPERUSER_RUN_CRON' => 'Run cron jobs'
]
);
}
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage cron tasks.', 403);
}
return $this->requirePermission($permission);
}
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
}
private function toBool(mixed $value, bool $default): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === null) {
return $default;
}
$normalized = strtolower(trim((string)$value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $default;
}
}
@@ -16,11 +16,12 @@ class customerAttributes
$this->get('/customer/attributes', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('list_customer_attributes');
// Get the user object
$user = (new authentication())->get_user();
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
// Check if the request was successful
if ($user) {
if ($user || $subuser) {
// Get the query parameters from the URL
$data = $_GET;
// Check if the required fields are set
@@ -32,14 +33,19 @@ class customerAttributes
$response->error('Customer Number must be a number', 400);
}
// Check if the user exists
if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) {
$target_user = (new users_o())->automaticGetTargetUserFromRequest();
if (!$target_user->exists()) {
$response->error('Customer not found', 400);
}
if (!$this->canListTargetCustomerAttributes($target_user)) {
$this->requirePermission('list_customer_attributes');
}
// Log the incident
(new logs_o())->add('customer_attributes', 'global', 1, $user->id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
$actor_id = $user !== false ? (int)$user->id : (int)($subuser->id ?? 0);
(new logs_o())->add('customer_attributes', 'global', 1, $actor_id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
// Return the list of customer notes
$response->success(
(new users_o())->automaticGetTargetUserFromRequest()->getUserAttributes()
$target_user->getUserAttributes()
);
} else {
// Log the incident
@@ -49,7 +55,7 @@ class customerAttributes
}
},
[
'list_customer_attributes' => 'List all customer attributes'
'list_customer_attributes' => 'List all customer attributes. Authenticated customer accounts may list their own customer attributes without this permission.'
]
);
@@ -127,4 +133,29 @@ class customerAttributes
]
);
}
}
private function canListTargetCustomerAttributes(users_o $target_user): bool
{
if (!$target_user->exists()) {
return false;
}
$target_customer_number = (int)$target_user->customer_number->value();
if ($target_customer_number <= 0) {
return false;
}
$auth = new authentication();
$user = $auth->get_user();
if (
$user !== false
&& $user->exists()
&& $this->hasPermission('user')
&& (int)$user->customer_number->value() === $target_customer_number
) {
return true;
}
return $auth->get_subuser() !== false && $this->isOwnCustomerContext($target_customer_number);
}
}
+20 -7
View File
@@ -575,11 +575,6 @@ class departmentsRoute
}
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
{
if (!$enabled) {
// Self-serve disabled: do not mutate lane relay states.
return;
}
$selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
@@ -595,12 +590,30 @@ class departmentsRoute
continue;
}
// Self-serve enabled: keep machine stack off.
if (!$enabled) {
// Self-serve disabled: restore normal/manual relay operation.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatusHard(true);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusHard(true);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
$lane->setMachineRelayStatusHard(true);
});
continue;
}
if (!$department_lane->isSelfServeEnabled()) {
continue;
}
// Self-serve enabled: lances must be usable; machine-only relays stay off.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatus(false);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatus(false);
$lane->setMachineCleanerRelayStatusHard(true);
});
try {
$lane->setMachineRelayStatus(false);
@@ -44,6 +44,33 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices',
]);
$this->get('/limited-backoffice/departments/{departmentId}/customer-pricing', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING);
$departmentId = $this->routePositiveInt('departmentId');
$customerUserId = $this->queryCustomerUserId();
return $service->getDepartmentCustomerPricing($user, $departmentId, $customerUserId);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING => 'View limited backoffice department customer pricing',
]);
$this->put('/limited-backoffice/departments/{departmentId}/customer-pricing', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING);
$departmentId = $this->routePositiveInt('departmentId');
$payload = $this->requestPayload();
$customerUserId = $this->payloadCustomerUserId($payload);
return $service->updateDepartmentCustomerPricing($user, $departmentId, $customerUserId, $payload);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING => 'Manage limited backoffice department customer pricing',
]);
$this->get('/limited-backoffice/roles', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -78,6 +105,15 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/migrate', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission('superuser');
return $service->migrateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
});
}, [
'superuser' => 'Migrate existing employees to limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -145,4 +181,59 @@ class limitedBackofficeRoute
}
return (int)$value;
}
private function queryPositiveInt(string $name): int
{
$value = $this->fromQuery($name);
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
throw new limited_backoffice_exception('Invalid query parameter.', 400);
}
return (int)$value;
}
private function queryCustomerUserId(): int
{
if ($this->fromQuery('user_id') !== null) {
return $this->queryPositiveInt('user_id');
}
return $this->userIdFromCustomerNumber($this->queryPositiveInt('customer_number'));
}
/**
* @param array<string, mixed> $payload
*/
private function payloadPositiveInt(array $payload, string $name): int
{
$value = $payload[$name] ?? null;
if (is_int($value) && $value > 0) {
return $value;
}
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
throw new limited_backoffice_exception('Invalid request parameter.', 400);
}
return (int)$value;
}
/**
* @param array<string, mixed> $payload
*/
private function payloadCustomerUserId(array $payload): int
{
if (array_key_exists('user_id', $payload)) {
return $this->payloadPositiveInt($payload, 'user_id');
}
return $this->userIdFromCustomerNumber($this->payloadPositiveInt($payload, 'customer_number'));
}
private function userIdFromCustomerNumber(int $customerNumber): int
{
$customer = (new \objects\users_o())->getUserByCustomerNumber($customerNumber);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return (int)$customer->id;
}
}
@@ -40,11 +40,15 @@ class orderBookingRoute
$reference = self::getTargetReference(); // String | Null
$po = self::getTargetPo(); // String | Null
$pickup = self::getTargetPickup(); // Bool | Null
$items = self::getTargetItems(); // Array of order_items_o objects
$this->requireOrderBookingCreateAccess(
(int)$customer_number->customer_number->value(),
(int)$department->id
);
$items = self::getTargetItems(
true,
$customer_number,
(int)$department->id
); // Array of order_items_o objects
/**
* Input data
*/
@@ -257,7 +261,6 @@ class orderBookingRoute
$reference = self::getTargetReference(false); // String | Null
$po = self::getTargetPo(false); // String | Null
$pickup = self::getTargetPickup(false); // Bool | Null
$items = self::getTargetItems(false); // Array of order_items_o objects
$order_id_was_set = self::isParametersSet(['order_id']);
$order_id = self::getTargetOrderId(false); // Int | Null
/** Authentication */
@@ -284,6 +287,11 @@ class orderBookingRoute
$ownGuard,
'You do not have permission to edit this order booking.'
);
$items = self::getTargetItems(
false,
$customer_number ?: (new users_o())->getUserByCustomerNumber((int)$object->customer_number->value()),
$department !== null ? (int)$department->id : (int)$object->department->value()
); // Array of order_items_o objects
/**
* Update the object
*/
@@ -796,7 +804,11 @@ class orderBookingRoute
/**
* @throws Exception
*/
private function getTargetItems(bool $required = true): array|null {
private function getTargetItems(
bool $required = true,
?users_o $customer = null,
?int $department_id = null
): array|null {
global $response;
$parameter = 'items';
$error = 'Invalid items';
@@ -811,11 +823,52 @@ class orderBookingRoute
foreach ($items as $key => $item) {
self::requireType($item, self::type_array());
self::requireValidItem((array)$item, $key);
$items[$key]['name'] = (new products_o())->select((int)$item['id'])->name->value();
$items[$key] = $this->normalizeBookingItem((array)$item, $customer, $department_id);
}
return $items;
}
/**
* @throws Exception
*/
private function normalizeBookingItem(array $item, ?users_o $customer, ?int $department_id): array
{
global $response;
$product = (new products_o())->select((int)$item['id']);
if (!$product->exists()) {
$response->error('Invalid item id', 400);
}
$price = (int)$product->price->value();
if ($department_id !== null) {
$priceResolution = $product->getDepartmentPriceResolution($department_id);
$price = (int)$priceResolution['price'];
if (
$customer !== null
&& $customer->exists()
&& !products_o::priceResolutionIsCustomMissing($priceResolution)
) {
$price = $customer->applyProductCustomerPricing(
(int)$product->id,
$price,
true,
$department_id
);
}
} elseif ($customer !== null && $customer->exists()) {
$price = $customer->applyProductCustomerPricing((int)$product->id, $price);
}
return [
...$item,
'id' => (int)$product->id,
'name' => (string)$product->name->value(),
'quantity' => (int)$item['quantity'],
'price' => $price,
];
}
private function cleanReg(string $reg): string
{
// Remove all non-alphanumeric characters
@@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary;
use classes\economic_v2_compare_engine;
use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service;
use classes\invoice_collection_bulk_action_service;
use classes\invoicing_period_utils;
use classes\response;
use classes\router;
@@ -711,6 +712,105 @@ class orderInvoicesRoute
]
);
/** Collected order invoices > Bulk action preview > POST */
$this->post('/collected-invoices/bulk-actions/preview', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'PREVIEW_COLLECTED_INVOICE_BULK_ACTION', 'User tried to preview a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['action', 'invoice_collection_ids']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$preview = (new invoice_collection_bulk_action_service())->preview(
$action,
$invoice_collection_ids,
$options,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user->id,
'PREVIEW_COLLECTED_INVOICE_BULK_ACTION',
'User previewed collected invoice bulk action ' . $action . ' for ' . count($invoice_collection_ids) . ' invoice collections'
);
$response->success($preview);
},
[
'reset_collected_invoice_economic' => 'Preview collected invoice bulk cleanup and price reset actions. This is a superuser-only route.',
'move_collected_invoice' => 'Preview merging selected collected invoices. This is a superuser-only route.',
'split_collected_invoice' => 'Preview splitting selected collected invoices by order month. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Preview queueing selected collected invoices for E-Conomic. This is a superuser-only route.',
]
);
/** Collected order invoices > Bulk action apply > POST */
$this->post('/collected-invoices/bulk-actions/apply', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'APPLY_COLLECTED_INVOICE_BULK_ACTION', 'User tried to apply a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$result = (new invoice_collection_bulk_action_service())->apply(
(string)self::getParameter('preview_id'),
$action,
$invoice_collection_ids,
$options,
(string)self::getParameter('confirmation_text'),
(int)$user->id,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success($result);
},
[
'reset_collected_invoice_economic' => 'Apply collected invoice bulk cleanup and price reset actions after confirmation. This is a superuser-only route.',
'move_collected_invoice' => 'Apply merging selected collected invoices after confirmation. This is a superuser-only route.',
'split_collected_invoice' => 'Apply splitting selected collected invoices by order month after confirmation. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Apply queueing selected collected invoices for E-Conomic after confirmation. This is a superuser-only route.',
]
);
/** Collected order invoices > E-Conomic > POST (queued) */
$this->post('/collected-invoices/economic', function () {
global $response;
@@ -2521,6 +2621,20 @@ class orderInvoicesRoute
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
}
private function requireCollectedInvoiceBulkActionPermission(string $action): void
{
$permission = match ($action) {
invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES,
invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES => 'reset_collected_invoice_economic',
invoice_collection_bulk_action_service::ACTION_MERGE => 'move_collected_invoice',
invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH => 'split_collected_invoice',
invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC => 'add_collected_invoice_economic',
default => throw new Exception('Invalid invoice collection bulk action.'),
};
self::requirePermission($permission);
}
/**
* @param $collected_order_invoice
* @param users_o $users
+32 -8
View File
@@ -75,13 +75,12 @@ class orderItemsRoute
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
$customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) {
@@ -95,6 +94,9 @@ class orderItemsRoute
);
$response->error($customerRuleViolation['message'], 400);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o());
@@ -173,18 +175,38 @@ class orderItemsRoute
$this->delete('/order/items', function () {
// Require the user to be logged in
global $response;
global $response, $db;
$this->requirePermission('delete_order_items');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Get the query data
$data = $_GET;
// Check if the required fields are set
if (!isset($data['id'])) {
// Get the order item id from the query string or request body
$itemIdRaw = $this->fromRequest('id');
if ($itemIdRaw === null || $itemIdRaw === '') {
$response->error('Order Item ID is required', 400);
}
$data = ['id' => $itemIdRaw];
// Look up the order item to check department access
$itemId = (int)$data['id'];
$stmt = $db->prepare('SELECT oi.order_id FROM order_items oi WHERE oi.id = ? LIMIT 1');
if ($stmt === false) {
(new logs_o())->add('order_items', 'global', 1, 0, 'DELETE_ORDER_ITEMS', 'Database error while preparing department access check query');
$response->error('Database error while checking department access', 500);
}
$stmt->bind_param('i', $itemId);
$stmt->execute();
$orderItemRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($orderItemRow !== null) {
$orderForAccess = (new orders_o())->getOrderById((int)$orderItemRow['order_id']);
if (!$orderForAccess->exists()) {
$response->error('Order not found', 404);
}
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
} else {
$response->error('Order item not found', 404);
}
// Delete the order item
(new order_items_o())->removeOrderItem((int)$data['id']);
// Return the list of departments
@@ -261,6 +283,8 @@ class orderItemsRoute
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$data['department_id']);
// Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Get the base64 file
$base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Delete the attachment
$order->removeAttachment((int)$attachment_id);
// Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value());
// Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
}
// Admin/department path (requires edit_order)
self::requirePermission($permission_other);
/** Departmental access */
/** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the target department
self::requireDepartmentAccess((string)(int)$data['department_id']);
$order->department_id->set((int)$data['department_id']);
}
// If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{
try {
$user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
} catch (\Throwable) {
return false;
}
+119 -51
View File
@@ -20,13 +20,17 @@ class productsRoute
* Get the customer object if the customer_id parameter is provided (In the request 'customer_id')
* @return users_o|null
*/
private function getCustomerIfProvided(): ?users_o
private function getCustomerIfProvided(bool $restrictToOwnCustomer = false): ?users_o
{
$customerId = $this->getOptionalPositiveIntParameter('customer_id');
$customerId = $this->getCustomerIdIfProvided();
if ($customerId === null) {
return null;
}
if ($restrictToOwnCustomer && !$this->isOwnCustomerContext($customerId)) {
$this->emitForbidden(['list_products']);
}
try {
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
if ($customerObject->exists()) {
@@ -42,6 +46,11 @@ class productsRoute
return null;
}
private function getCustomerIdIfProvided(): ?int
{
return $this->getOptionalPositiveIntParameter('customer_id');
}
/**
* Get the department id if the department_id parameter is provided (In the request 'department_id')
* @return int|null
@@ -92,17 +101,40 @@ class productsRoute
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
}
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
private function isCustomerBookingSession(bool $hasAuthenticatedUser, bool $hasCustomerPermission, bool $isSubuserSession): bool
{
if (!$user instanceof users_o || $departmentId === null) {
return;
return ($hasAuthenticatedUser && $hasCustomerPermission) || $isSubuserSession;
}
private function canReadProductList(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool
{
return $isCustomerBookingSession || $hasListProductsPermission;
}
private function shouldRestrictCustomerBookingProducts(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool
{
return $isCustomerBookingSession && !$hasListProductsPermission;
}
private function canUseCustomerBookingDepartmentPricing(bool $isCustomerBookingSession, bool $useFinalPrice, ?int $customerId): bool
{
if (!$isCustomerBookingSession || !$useFinalPrice) {
return false;
}
if ($this->hasPermission('superuser_fetch_department')) {
return;
}
return $customerId === null || $this->isOwnCustomerContext($customerId);
}
$this->requirePermission('department_access_' . $departmentId);
private function isBookingVisibleProduct(array $product): bool
{
return (bool)($product['display_in_booking_form'] ?? false);
}
private function filterProductsVisibleOnBookingForm(array $products): array
{
return array_values(array_filter($products, function ($product): bool {
return is_array($product) && $this->isBookingVisibleProduct($product);
}));
}
/**
@@ -140,7 +172,7 @@ class productsRoute
// Check if the customer is set
if ($customer !== null) {
// Apply the customers unique discounts
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
$products = (new products_o())->applyCustomerDiscounts($products, $customer, $departmentId);
} else {
$products = products_o::stripDepartmentPriceSources($products);
}
@@ -178,30 +210,44 @@ class productsRoute
global $response;
$permission_node = 'list_products';
$isProductDetailsRestricted = true;
if ($this->isAuthenticated()) {
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
$hasAuthenticatedUser = $user !== false && $user !== null;
$isSubuserSession = $subuser !== false;
$hasCustomerPermission = $hasAuthenticatedUser ? self::hasPermission('user') : false;
$isCustomerBookingSession = $this->isCustomerBookingSession($hasAuthenticatedUser, $hasCustomerPermission, $isSubuserSession);
$hasListProductsPermission = $hasAuthenticatedUser ? self::hasPermission($permission_node) : false;
if ($hasAuthenticatedUser || $isSubuserSession) {
$isProductDetailsRestricted = false;
$this->requirePermission($permission_node);
if (!$this->canReadProductList($isCustomerBookingSession, $hasListProductsPermission)) {
$this->emitForbidden([$permission_node]);
}
}
// Get the user object
$user = (new authentication())->get_user();
// Set the user id to 0 if guest
$responsibleUserId = $isProductDetailsRestricted ? 0 : $user->id;
function parseProduct($product, $isGuest): array
{
$responsibleUserId = $hasAuthenticatedUser ? (int)$user->id : 0;
$parseProduct = function ($product, $isGuest, $onlyBookingVisible = false): array {
$addons = (new product_options_o())->getProductOptions($product['id']);
if ($onlyBookingVisible) {
$addons = array_values(array_filter($addons, function ($option): bool {
return (bool)($option['product']['display_in_booking_form'] ?? false);
}));
}
$tmpProduct = [
'id' => (int)$product['id'],
'name' => (string)$product['name'],
'description' => (string)$product['description'],
'price' => (int)$product['price'],
'subscription_allowed' => (boolean)$product['subscription_allowed'],
'subscription_allowed' => (bool)$product['subscription_allowed'],
'category' => (int)$product['category'],
'piktogram' => (string)$product['piktogram'],
'economic_product_id' => (int)$product['economic_product_id'],
'apply_category_discount' => (boolean)$product['apply_category_discount'],
'apply_category_discount' => (bool)$product['apply_category_discount'],
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'],
'addons' => (new product_options_o())->getProductOptions($product['id']),
'addons' => $addons,
'is_wash' => (bool)$product['is_wash'],
'display_in_booking_form' => (bool)$product['display_in_booking_form'],
'order_priority' => (int)$product['order_priority'],
@@ -236,19 +282,27 @@ class productsRoute
];
}
return $isGuest ? $tmpProductGuest : $tmpProduct;
}
};
// Check if the request was successful
if ($user || $isProductDetailsRestricted) {
if ($hasAuthenticatedUser || $isSubuserSession || $isProductDetailsRestricted) {
// Define the variables
$customer = $this->getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$restrictCustomerBookingProducts = $this->shouldRestrictCustomerBookingProducts($isCustomerBookingSession, $hasListProductsPermission);
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
$customerId = $this->getCustomerIdIfProvided();
$customer = $this->getCustomerIfProvided($restrictCustomerBookingProducts); // This is only used if the customer_id parameter is provided
$departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
if (
$departmentId !== null
&& !$restrictCustomerBookingProducts
&& !$this->canUseCustomerBookingDepartmentPricing($isCustomerBookingSession, $useFinalPrice, $customerId)
) {
self::requireDepartmentAccess((string)$departmentId);
}
$category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
// Check if the "final_price" parameter is set, and true.
if ($useFinalPrice) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Determine the products to return
if ($category) {
// Get products in the category
@@ -271,21 +325,24 @@ class productsRoute
} else {
// Get all products
$products = (array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
function ($product) use ($isProductDetailsRestricted, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted);
}
);
}
if ($restrictCustomerBookingProducts && !$productId) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Return all products, with the department pricing and customer discounts applied
//$response->success(
// array_map(function ($product) {
// return parseProduct($product);
// }, self::parseProductsPrice($products, $customer, $departmentId))
//);
$result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted) {
$productArray = parseProduct($product, $isProductDetailsRestricted);
$result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
$productArray = $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
// Get the price of the product with the department pricing and customer discounts applied
$productArray = parseProduct(self::parseProductsPrice([$productArray], $customer, $departmentId)[0], $isProductDetailsRestricted);
$productArray = $parseProduct(self::parseProductsPrice([$productArray], $customer, $departmentId)[0], $isProductDetailsRestricted, $restrictCustomerBookingProducts);
// Get the options for the product
$productArray['addons'] = self::parseOptionsPrice($productArray['addons'], $customer, $departmentId);
// Return the product with the updated price
@@ -299,9 +356,13 @@ class productsRoute
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed product with id ' . $response->getRequestParameter('id'));
// Return the product
$product = (new products_o())->select((int)self::getParameter('id'))->asArray();
if ($restrictCustomerBookingProducts && !$this->isBookingVisibleProduct($product)) {
$response->success([]);
}
$response->success(
parseProduct(
(new products_o())->select((int)self::getParameter('id'))->asArray(), $isProductDetailsRestricted
$parseProduct(
$product, $isProductDetailsRestricted, $restrictCustomerBookingProducts
)
);
}
@@ -312,15 +373,17 @@ class productsRoute
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
// Return the list of products
$products = (new products_o())->listObjectsByCategory($category);
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Check if the department_id is set
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
}
$response->success(
array_map(function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
array_map(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
}, $products)
);
}
@@ -328,32 +391,37 @@ class productsRoute
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Get all product ids contained in a category attached to the department
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
// Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) {
return $product->id;
}, $departmentSpecificProducts);
$products = (array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
// Only include products that are in the department specific product ids
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
},
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
);
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Return the list of products
$response->success(
(new products_o())->applyDepartmentPricing((array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted, $departmentSpecificProductIds) {
// Only include products that are in the department specific product ids
return parseProduct($product, $isProductDetailsRestricted);
},
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
), $departmentId)
(new products_o())->applyDepartmentPricing($products, $departmentId)
);
}
// Return the list of products
$response->success(
(new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
})
);
$products = (new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
});
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm((array)$products);
}
$response->success($products);
} else {
// Log the incident
(new logs_o())->add('products', 'global', 1, 0, 'LIST_PRODUCTS', 'No user found, or invalid session');
@@ -362,7 +430,7 @@ class productsRoute
}
},
[
'list_products' => 'List all products'
'list_products' => 'List all products. Authenticated customer booking sessions may read booking-visible products without the permission.'
]
);
+21 -1
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o;
use objects\logs_o;
use traits\route_t;
@@ -106,6 +107,25 @@ class rolesRoute
]
);
self::get('/roles/limited-backoffice-permission-templates', function () {
global $response;
self::requirePermission('superuser');
self::requirePermission('add_role_permission');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
} else {
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
$response->error('Invalid session', 400);
}
},
[
'superuser' => 'Access the superuser interface',
'add_role_permission' => 'Add a permission to a role'
]
);
self::post('/roles/permissions', function () {
// Require the user to be logged in
global $response;
@@ -182,4 +202,4 @@ class rolesRoute
]
);
}
}
}
+522 -35
View File
@@ -6,6 +6,7 @@ use classes\authentication;
use classes\economic;
use classes\gatewayapi;
use classes\response;
use classes\subuser_permission_templates_service;
use classes\virkdata;
use Exception;
use modules\virkdata\helpers\virkdata_response;
@@ -120,6 +121,27 @@ class subusersRoute
return array_values(array_unique($permissions));
}
/**
* @return array{enabled:bool,permissions:array<int,string>}|null
*/
private function parseAccessTemplatePayload(): ?array
{
global $response;
if (!self::isParametersSet(['permission_template_key'])) {
return null;
}
$templateKey = (string)self::getParameter('permission_template_key');
try {
return (new subuser_permission_templates_service())->expandTemplate($templateKey);
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
return null;
}
private function normalizeOptionalString(mixed $value): ?string
{
if ($value === null) {
@@ -337,6 +359,7 @@ class subusersRoute
{
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
$templateService = new subuser_permission_templates_service();
$setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired;
@@ -370,6 +393,8 @@ class subusersRoute
'grant_note' => $grant ? $grant->note->value() : null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'access_state' => $accessState,
];
}
@@ -429,8 +454,8 @@ class subusersRoute
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'g.`billing_customer_number`',
'grant_id' => 'g.`id`',
'customer_number' => 'customer_number_sort',
'grant_id' => 'grant_id_sort',
'name' => 's.`name`',
];
@@ -462,10 +487,10 @@ class subusersRoute
$statement->bind_param($types, ...$refs);
}
private function buildSuperuserSubuserManagementPayload(array $row): array
private function buildSuperuserGrantPayload(array $row, bool $setupRequired): array
{
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
$templateService = new subuser_permission_templates_service();
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$accessState = 'inactive';
@@ -475,6 +500,44 @@ class subusersRoute
$accessState = 'disabled';
}
return [
'grant_id' => (int)$row['grant_id'],
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'access_state' => $accessState,
];
}
private function buildSuperuserSubuserManagementPayload(array $row, array $grantRows = []): array
{
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
if ($grantRows === [] && !empty($row['grant_id'])) {
$grantRows = [$row];
}
$grants = array_map(
fn (array $grantRow): array => $this->buildSuperuserGrantPayload($grantRow, $setupRequired),
$this->dedupeSuperuserGrantRows($grantRows)
);
$primaryGrant = $this->selectPrimarySuperuserGrant($grants);
$accessState = 'inactive';
if (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'active') !== []) {
$accessState = 'active';
} elseif (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'pending_setup') !== []) {
$accessState = 'pending_setup';
} elseif ($grants !== []) {
$accessState = 'disabled';
}
return [
'id' => (int)$row['id'],
'username' => $row['username'] ?? null,
@@ -490,20 +553,178 @@ class subusersRoute
'invite_accepted' => !$setupRequired,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_id' => (int)$row['grant_id'],
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'customer_number' => $primaryGrant['customer_number'] ?? null,
'customer_name' => $primaryGrant['customer_name'] ?? null,
'grant_id' => $primaryGrant['grant_id'] ?? null,
'grant_enabled' => $primaryGrant['grant_enabled'] ?? false,
'grant_note' => $primaryGrant['grant_note'] ?? null,
'grant_permissions' => $primaryGrant['grant_permissions'] ?? [],
'permissions' => $primaryGrant['permissions'] ?? [],
'permission_template_key' => $primaryGrant['permission_template_key'] ?? subuser_permission_templates_service::TEMPLATE_DEACTIVATED,
'permission_groups' => $primaryGrant['permission_groups'] ?? [],
'grant_created_at' => $primaryGrant['grant_created_at'] ?? null,
'grant_updated_at' => $primaryGrant['grant_updated_at'] ?? null,
'grants' => $grants,
'grant_count' => count($grants),
'customer_numbers' => array_values(array_unique(array_map(
static fn (array $grant): int => (int)$grant['customer_number'],
$grants
))),
'access_state' => $accessState,
];
}
private function listSuperuserSubusers(): array
private function dedupeSuperuserGrantRows(array $grantRows): array
{
$byCustomer = [];
foreach ($grantRows as $grantRow) {
$customerNumber = (int)($grantRow['customer_number'] ?? 0);
if ($customerNumber <= 0) {
continue;
}
$existing = $byCustomer[$customerNumber] ?? null;
if ($existing === null || $this->compareSuperuserGrantRows($grantRow, $existing) < 0) {
$byCustomer[$customerNumber] = $grantRow;
}
}
$deduped = array_values($byCustomer);
usort($deduped, fn (array $left, array $right): int => $this->compareSuperuserGrantRows($left, $right));
return $deduped;
}
private function compareSuperuserGrantRows(array $left, array $right): int
{
$leftEnabled = (int)($left['grant_enabled'] ?? 0);
$rightEnabled = (int)($right['grant_enabled'] ?? 0);
if ($leftEnabled !== $rightEnabled) {
return $rightEnabled <=> $leftEnabled;
}
$leftCustomer = (int)($left['customer_number'] ?? 0);
$rightCustomer = (int)($right['customer_number'] ?? 0);
if ($leftCustomer !== $rightCustomer) {
return $leftCustomer <=> $rightCustomer;
}
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
}
private function selectPrimarySuperuserGrant(array $grants): ?array
{
if ($grants === []) {
return null;
}
$sorted = $grants;
usort($sorted, static function (array $left, array $right): int {
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
$leftCreated = strtotime((string)($left['grant_created_at'] ?? '')) ?: 0;
$rightCreated = strtotime((string)($right['grant_created_at'] ?? '')) ?: 0;
if ($leftCreated !== $rightCreated) {
return $rightCreated <=> $leftCreated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
});
return $sorted[0];
}
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function resolveSuperuserSubuserTargetUser(int $userId): array
{
global $response;
$targetUser = (new users_o())->select($userId);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
$targetUser->getObjectProperties();
$customerNumber = (int)$targetUser->customer_number->value();
if ($customerNumber <= 0) {
$response->error('Selected user does not have a customer number', 400);
}
$customerName = $targetUser->display_name->value();
if (!is_string($customerName) || trim($customerName) === '') {
$customerName = $this->resolveCustomerName($customerNumber);
}
return [
'user_id' => (int)$targetUser->id,
'customer_number' => $customerNumber,
'customer_name' => $customerName,
];
}
private function buildSubuserSummaryForCustomer(int $customerNumber): array
{
global $db;
$statement = $db->conn->prepare("
SELECT
COUNT(*) AS `total`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') <> '' THEN 1 ELSE 0 END) AS `active`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') = '' THEN 1 ELSE 0 END) AS `pending_setup`,
SUM(CASE WHEN g.`enabled` = 0 THEN 1 ELSE 0 END) AS `disabled`
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
WHERE g.`deleted_at` IS NULL
AND g.`billing_customer_number` = ?
");
if ($statement === false) {
throw new Exception('Failed to prepare subuser summary query: ' . $db->conn->error);
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$row = $result->fetch_assoc() ?: [];
$statement->close();
return [
'total' => (int)($row['total'] ?? 0),
'active' => (int)($row['active'] ?? 0),
'pending_setup' => (int)($row['pending_setup'] ?? 0),
'disabled' => (int)($row['disabled'] ?? 0),
];
}
private function addUserScopedSubuserMeta(array $targetUser): void
{
global $response;
$response->add_meta('user_context', $targetUser);
$response->add_meta('subusers_summary', $this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}
private function listSuperuserSubusers(?int $customerNumber = null): array
{
global $db, $response;
@@ -521,6 +742,11 @@ class subusersRoute
if (!$includeNonEnabled) {
$where[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$where[] = 'g.`billing_customer_number` = ?';
$params[] = $customerNumber;
$types .= 'i';
}
if ($pagination['search'] !== null) {
$where[] = "(
@@ -532,7 +758,12 @@ class subusersRoute
OR CAST(s.`phone` AS CHAR) LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` LIKE ?
OR u.`display_name` LIKE ?
OR EXISTS (
SELECT 1
FROM `users` search_u
WHERE search_u.`customer_number` = g.`billing_customer_number`
AND search_u.`display_name` LIKE ?
)
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) {
@@ -545,10 +776,9 @@ class subusersRoute
$fromSql = "
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number`
";
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
$countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
@@ -559,7 +789,7 @@ class subusersRoute
$total = (int)($countResult->fetch_assoc()['count'] ?? 0);
$countStatement->close();
$dataSql = "
$pageSql = "
SELECT
s.`id`,
s.`username`,
@@ -572,31 +802,100 @@ class subusersRoute
s.`created_at`,
s.`updated_at`,
s.`suspended_at`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`,
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
u.`display_name` AS `customer_name`
MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`,
MIN(g.`billing_customer_number`) AS `customer_number_sort`,
MAX(g.`id`) AS `grant_id_sort`
$fromSql
$whereSql
GROUP BY
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ?
";
$dataStatement = $db->conn->prepare($dataSql);
if ($dataStatement === false) {
$pageStatement = $db->conn->prepare($pageSql);
if ($pageStatement === false) {
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
}
$dataParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams);
$dataStatement->execute();
$result = $dataStatement->get_result();
$pageParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($pageStatement, $types . 'ii', $pageParams);
$pageStatement->execute();
$result = $pageStatement->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);
$dataStatement->close();
$pageStatement->close();
$subuserIds = array_map(static fn (array $row): int => (int)$row['id'], $rows);
$grantRowsBySubuserId = [];
if ($subuserIds !== []) {
$placeholders = implode(',', array_fill(0, count($subuserIds), '?'));
$grantWhere = [
'g.`deleted_at` IS NULL',
'g.`subuser` IN (' . $placeholders . ')',
];
$grantParams = $subuserIds;
$grantTypes = str_repeat('i', count($subuserIds));
if (!$includeNonEnabled) {
$grantWhere[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$grantWhere[] = 'g.`billing_customer_number` = ?';
$grantParams[] = $customerNumber;
$grantTypes .= 'i';
}
$grantSql = "
SELECT
g.`subuser` AS `subuser_id`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`
FROM `subuser_grants` g
WHERE " . implode(' AND ', $grantWhere) . "
ORDER BY
g.`subuser` ASC,
g.`enabled` DESC,
g.`billing_customer_number` ASC,
COALESCE(g.`updated_at`, g.`created_at`) DESC,
g.`id` DESC
";
$grantStatement = $db->conn->prepare($grantSql);
if ($grantStatement === false) {
throw new Exception('Failed to prepare subuser grant list query: ' . $db->conn->error);
}
$this->bindStatementParameters($grantStatement, $grantTypes, $grantParams);
$grantStatement->execute();
$grantResult = $grantStatement->get_result();
$grantRows = $grantResult->fetch_all(MYSQLI_ASSOC);
$grantStatement->close();
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grantRow): int => (int)($grantRow['customer_number'] ?? 0),
$grantRows
));
foreach ($grantRows as $grantRow) {
$subuserId = (int)$grantRow['subuser_id'];
$customerNumberForGrant = (int)$grantRow['customer_number'];
$grantRow['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
$grantRowsBySubuserId[$subuserId] ??= [];
$grantRowsBySubuserId[$subuserId][] = $grantRow;
}
}
$response->paginate(
(int)$pagination['page'],
@@ -607,7 +906,107 @@ class subusersRoute
[$pagination['order_field'] => $pagination['order_direction']]
);
return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows);
return array_map(
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, $grantRowsBySubuserId[(int)$row['id']] ?? []),
$rows
);
}
private function getGrantForScopedUserOrFail(int $grantId, int $customerNumber): subuser_grants_o
{
global $response;
$grant = (new subuser_grants_o())->select($grantId);
if (!$grant->exists()) {
$response->error('Subuser grant not found', 404);
}
$grant->getObjectProperties();
if ((int)$grant->billing_customer_number->value() !== $customerNumber || $grant->deleted_at->value() !== null) {
$response->error('Subuser grant not found for selected user', 404);
}
return $grant;
}
private function patchScopedSubuserGrant(int $grantId, int $customerNumber): void
{
global $response;
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
$updates = [];
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$updates['enabled'] = $templateAccess['enabled'];
$updates['permissions'] = $templateAccess['permissions'];
}
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($tmp === null) {
$response->error('Invalid enabled value', 400);
}
$updates['enabled'] = (bool)$tmp;
}
if (self::isParametersSet(['note'])) {
$note = $this->normalizeOptionalString(self::getParameter('note'));
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$updates['note'] = $note;
}
if (self::isParametersSet(['permissions'])) {
$updates['permissions'] = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
}
if ($updates === []) {
$response->error('No fields to update', 400);
}
try {
$grant->update($updates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$updatedGrant = (new subuser_grants_o())->select($grantId);
$updatedGrant->getObjectProperties();
$subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value());
$subuser->getObjectProperties();
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $updatedGrant->asArray(),
]);
}
private function resendInviteForScopedUser(int $subuserId, int $customerNumber): void
{
global $response;
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
$response->error('Subuser not found', 404);
}
$subuser->getObjectProperties();
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
if ($grant === null) {
$response->error('Subuser grant not found for selected user', 404);
}
if (!$subuser->requiresSetup()) {
$response->error('Driver account already accepted the invitation.', 409);
}
$invite = $this->issueSetupInvite($subuser);
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $grant->asArray(),
'invite' => $invite,
]);
}
private function handleInviteSubuserForCustomer(int $customerNumber): void
@@ -617,6 +1016,9 @@ class subusersRoute
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
if (self::isParametersSet(['customer_number']) && (int)self::getParameter('customer_number') !== $customerNumber) {
$response->error('Customer number does not match selected user', 400);
}
self::requireParameters(['name', 'phone_country_code', 'phone']);
@@ -624,6 +1026,7 @@ class subusersRoute
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$templateAccess = $this->parseAccessTemplatePayload();
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -632,6 +1035,10 @@ class subusersRoute
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
@@ -813,9 +1220,14 @@ class subusersRoute
self::requireType($note, self::type_string());
self::requireMaxLength('note', 65535);
}
$templateAccess = $this->parseAccessTemplatePayload();
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
$response->success(['grant' => $grant->asArray()]);
@@ -853,6 +1265,12 @@ class subusersRoute
}
}
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$grant->enabled->set($templateAccess['enabled']);
$grant->permissions->set($templateAccess['permissions']);
}
// Update fields provided in the request
if (self::isParametersSet(['enabled'])) {
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -923,6 +1341,25 @@ class subusersRoute
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
]);
$this->get('/subusers/permission-templates', function () {
global $response;
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|| self::hasPermission('list_subusers')
|| self::hasPermission('add_subusers')
|| self::hasPermission('edit_subusers');
if (!$canUseGlobalManagement) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
}
$response->success((new subuser_permission_templates_service())->accessModel());
}, [
'list_own_subusers' => 'List chauffeur permission templates for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
'manage_subuser_grants' => 'List chauffeur permission templates for administrative grant management.',
'list_subusers' => 'List chauffeur permission templates for superuser management.',
'add_subusers' => 'List chauffeur permission templates while inviting chauffeurs.',
'edit_subusers' => 'List chauffeur permission templates while editing chauffeur grants.',
]);
$this->post('/subusers', function () {
global /** @var response $response */
$response;
@@ -1150,6 +1587,26 @@ class subusersRoute
'list_subusers' => 'List all chauffeur access grants for superusers.',
]);
$this->get('/superuser/users/{user_id}/subusers', function () {
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->addUserScopedSubuserMeta($targetUser);
$response->success($this->listSuperuserSubusers((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'List chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/superuser/users/{user_id}/subusers/summary', function () {
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'Summarize chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/subusers', function () {
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
@@ -1264,6 +1721,14 @@ class subusersRoute
'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/invite', function () {
$this->requirePermission('add_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->handleInviteSubuserForCustomer((int)$targetUser['customer_number']);
}, [
'add_subusers' => 'Invite or link chauffeurs for a selected superuser customer account.',
]);
$this->post('/subusers/invite', function () {
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
$this->handleInviteSubuserForCustomer($customerNumber);
@@ -1306,6 +1771,28 @@ class subusersRoute
'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend', function () {
$this->requirePermission('edit_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->resendInviteForScopedUser(
$this->routePositiveInt('subuser_id'),
(int)$targetUser['customer_number']
);
}, [
'edit_subusers' => 'Resend chauffeur invites for a selected superuser customer account.',
]);
$this->patch('/superuser/users/{user_id}/subusers/grants/{grant_id}', function () {
$this->requirePermission('manage_subuser_grants');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->patchScopedSubuserGrant(
$this->routePositiveInt('grant_id'),
(int)$targetUser['customer_number']
);
}, [
'manage_subuser_grants' => 'Edit chauffeur grants for a selected superuser customer account.',
]);
$this->post('/subusers/invite/resend', function () {
global $response;
@@ -3,11 +3,14 @@
namespace routes;
use classes\authentication;
use classes\department_customer_pricing_service;
use classes\limited_backoffice_exception;
use objects\branding_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
use objects\products_o;
use objects\users_o;
use traits\route_t;
class superuserDepartmentRoute
@@ -38,9 +41,9 @@ class superuserDepartmentRoute
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT', 'Successfully fetched department');
// Return the department
$response->success(
(new departments_o())->getDepartmentById((int)$this->fromRequest('department_id'))
);
$department = (new departments_o())->getDepartmentById((int)$this->fromRequest('department_id'));
$department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
$response->success($department);
} else {
// Log the incident
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT', 'No user found, or invalid session');
@@ -213,6 +216,57 @@ class superuserDepartmentRoute
'superuser_fetch_department_prices' => 'Fetch department prices'
]);
$this->get('/superuser/department/customer-pricing', function () {
global $response;
$this->requirePermission('superuser_fetch_department_customer_pricing');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$departmentId = $this->positiveIntFromRequest('department_id');
$userId = $this->customerUserIdFromRequest();
try {
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'Successfully fetched department customer pricing');
$response->success((new department_customer_pricing_service())->getPricing($departmentId, $userId));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
}, [
'superuser_fetch_department_customer_pricing' => 'Fetch department customer pricing'
]);
$this->put('/superuser/department/customer-pricing', function () {
global $response;
$this->requirePermission('superuser_set_department_customer_pricing');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload)) {
$response->error('Invalid request body', 400);
}
$departmentId = $this->positiveIntFromPayload($payload, 'department_id');
$userId = $this->customerUserIdFromPayload($payload);
try {
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'Successfully set department customer pricing');
$response->success((new department_customer_pricing_service())->updatePricing($departmentId, $userId, $payload));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
}, [
'superuser_set_department_customer_pricing' => 'Set department customer pricing'
]);
$this->get('/superuser/department/variables', function () {
// Require the user to be logged in
global $response;
@@ -295,4 +349,78 @@ class superuserDepartmentRoute
]);
}
private function positiveIntFromRequest(string $name): int
{
global $response;
$value = $this->fromRequest($name);
if (!is_string($value) && !is_int($value)) {
$response->error($name . ' is required', 400);
}
return $this->positiveIntValue($value, $name);
}
private function customerUserIdFromRequest(): int
{
if ($this->fromRequest('user_id') !== null) {
return $this->positiveIntFromRequest('user_id');
}
return $this->userIdFromCustomerNumber($this->positiveIntFromRequest('customer_number'));
}
/**
* @param array<string, mixed> $payload
*/
private function positiveIntFromPayload(array $payload, string $name): int
{
global $response;
if (!array_key_exists($name, $payload)) {
$response->error($name . ' is required', 400);
}
return $this->positiveIntValue($payload[$name], $name);
}
/**
* @param array<string, mixed> $payload
*/
private function customerUserIdFromPayload(array $payload): int
{
if (array_key_exists('user_id', $payload)) {
return $this->positiveIntFromPayload($payload, 'user_id');
}
return $this->userIdFromCustomerNumber($this->positiveIntFromPayload($payload, 'customer_number'));
}
private function positiveIntValue(mixed $value, string $name): int
{
global $response;
if (is_int($value)) {
$parsed = $value;
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$parsed = (int)trim($value);
} else {
$response->error($name . ' must be a positive integer', 400);
}
if ($parsed <= 0) {
$response->error($name . ' must be a positive integer', 400);
}
return $parsed;
}
private function userIdFromCustomerNumber(int $customerNumber): int
{
global $response;
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
return (int)$customer->id;
}
}
@@ -33,10 +33,9 @@ class systemSearchRoute
$response->success([
'message' => 'System search cache cleared',
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_clear' => 'Clear system-wide search query and intent caches',
'superuser_search_system_cache_clear' => 'Clear system-wide search query caches',
]);
$this->post('/superuser/search/system/cache/rebuild', function () {
@@ -49,15 +48,12 @@ class systemSearchRoute
$types = $this->parseTypeList($params['types'] ?? []);
$request = system_search_cache::enqueueRebuild($scope, $types);
// Rebuild endpoint also clears parser namespace immediately.
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$response->success([
'message' => 'System search cache rebuild queued',
'request' => $request,
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild',
@@ -86,9 +82,7 @@ class systemSearchRoute
$includeTypes = $this->parseTypeList($params['include_types'] ?? []);
$excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []);
$includeAssociations = $this->toBool($params['include_associations'] ?? true, true);
$debugIntent = $this->toBool($params['debug_intent'] ?? false, false);
$limit = $this->clampInt((int)($params['limit'] ?? 50), 1, 200, 50);
$offset = max(0, (int)($params['offset'] ?? 0));
$maxResults = $this->clampMaxResults((int)($params['max_results'] ?? 50));
[$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes();
if (empty($allowedTypes)) {
@@ -118,9 +112,7 @@ class systemSearchRoute
'permissions_catalog_own' => $permissionsCatalogOwn,
'module_config_visibility' => $this->buildModuleConfigVisibility(),
'include_associations' => $includeAssociations,
'debug_intent' => $debugIntent,
'limit' => $limit,
'offset' => $offset,
'max_results' => $maxResults,
]);
$response->success($result);
@@ -596,6 +588,11 @@ class systemSearchRoute
return $value;
}
private function clampMaxResults(int $value): int
{
return $this->clampInt($value, 1, 50, 50);
}
private function allEntityTypes(): array
{
return array_keys($this->entityPermissionMap());
+3 -1
View File
@@ -33,9 +33,11 @@ class userRoute
// Return an error
$response->error('User not found', 404);
}
$targetUserData = $targetUser->includeIncludes(['all'])->asArray();
$targetUserData['limited_backoffice_managed'] = (new users_o())->isLimitedBackofficeManagedUser((int)$targetUser->id);
// Return the list of users
$response->success(
$targetUser->includeIncludes(['all'])->asArray()
$targetUserData
);
} else {
// Log the incident
+18 -3
View File
@@ -242,7 +242,7 @@ class usersRoute
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if (!$enabled || $filters === null || $filters === '') {
if ($filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
@@ -257,16 +257,31 @@ class usersRoute
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) {
// When include mode is on but the filter is not a customer_number:0 query,
// pass the original filter through as forced filters so they are not discarded.
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
// back to reading the filters from the request, which is equivalent.
return [
'enabled' => false,
'filters' => $filters,
'filters' => $enabled ? $filters : null,
'additional_where' => null,
];
}
unset($filterArray['customer_number']);
// $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
if (!$enabled) {
// Exclude active limited backoffice employees when the include flag is not set.
return [
'enabled' => false,
'filters' => null,
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
];
}
unset($filterArray['customer_number']);
return [
'enabled' => true,
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
+392
View File
@@ -21,6 +21,311 @@ class vehiclesRoute
{
use route_t;
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function resolveSuperuserVehicleTargetUser(int $userId): array
{
global $response;
$targetUser = (new users_o())->select($userId);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
$targetUser->getObjectProperties();
$customerNumber = (int)$targetUser->customer_number->value();
if ($customerNumber <= 0) {
$response->error('Selected user does not have a customer number', 400);
}
return [
'user_id' => (int)$targetUser->id,
'customer_number' => $customerNumber,
'customer_name' => (string)$targetUser->getCustomerName($customerNumber),
];
}
private function addUserScopedVehicleMeta(array $targetUser): void
{
global $response;
$response->add_meta('user_context', $targetUser);
$response->add_meta('vehicles_summary', $this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
}
private function buildVehiclePayload(array $vehicle): array
{
return [...(new customer_vehicles_o())->select((int)$vehicle['id'])->asArray()];
}
private function listVehiclesForCustomer(int $customerNumber): array
{
$vehicles = new customer_vehicles_o();
return $vehicles->listObjectsWithPaginationIfSet(
fn ($vehicle) => $this->buildVehiclePayload($vehicle),
$vehicles->forceRestrictFilters([
'customer_id' => [$customerNumber],
])
);
}
private function buildVehicleSummaryForCustomer(int $customerNumber): array
{
$vehicles = new customer_vehicles_o();
$filters = [
'customer_id' => $customerNumber,
];
if ($vehicles->columnsExist(['deleted_at'])) {
$filters['deleted_at'] = null;
}
$rows = $vehicles->getFieldsWhere($filters, [
'id',
'wash_subscription',
]);
$summary = [
'total' => 0,
'wash_subscription' => 0,
'self_service' => 0,
];
foreach ($rows as $row) {
$summary['total']++;
if ((int)($row['wash_subscription'] ?? 0) === 1) {
$summary['wash_subscription']++;
}
try {
$vehicle = (new customer_vehicles_o())->select((int)$row['id']);
if ($vehicle->exists() && $vehicle->hasXLVask()) {
$summary['self_service']++;
}
} catch (\Throwable) {
// XLVask availability should not prevent the customer vehicle summary from loading.
}
}
return $summary;
}
private function requireScopedVehicle(int $vehicleId, int $customerNumber): customer_vehicles_o
{
global $response;
$vehicle = (new customer_vehicles_o())->select($vehicleId);
if (!$vehicle->exists()) {
$response->error('Vehicle not found', 404);
}
$vehicle->getObjectProperties();
if ((int)$vehicle->customer_id->value() !== $customerNumber) {
$response->error('Vehicle does not belong to selected user', 404);
}
return $vehicle;
}
private function validateOptionalCustomerIdMatches(int $customerNumber): void
{
global $response;
if (!self::isParametersSet(['customer_id'])) {
return;
}
$requestedCustomerNumber = (int)self::getParameter('customer_id');
self::requireType($requestedCustomerNumber, self::type_int());
if ($requestedCustomerNumber !== $customerNumber) {
$response->error('Customer number does not match selected user', 400);
}
}
private function createVehicleForCustomer(int $customerNumber): array
{
global $response;
self::requireParameters([
'type',
'reg',
]);
$this->validateOptionalCustomerIdMatches($customerNumber);
$reference = null;
if (self::isParametersSet(['reference']) && !empty(self::getParameter('reference'))) {
$reference = (string)self::getParameter('reference');
self::requireType($reference, self::type_string());
self::requireMinLength('reference', 1);
self::requireMaxLength('reference', 255);
}
self::requireType(self::getParameter('reg'), self::type_string());
self::requireType(self::getParameter('type'), self::type_int());
$subscription = false;
if (self::isParametersSet(['wash_subscription'])) {
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
$subscription = (bool)self::getParameter('wash_subscription');
}
$reg = trim((string)self::getParameter('reg'));
self::requireMinLength('reg', 2);
self::requireMaxLength('reg', 12);
$type = (int)self::getParameter('type');
$vehicle = new customer_vehicles_o();
$vehicle->add($customerNumber, $type, $reg, $subscription, $reference);
try {
(new economic_v2_versioning_service())->recordVehicleSubscriptionVersion(
[
'vehicle_id' => (int)$vehicle->id,
'customer_number' => $customerNumber,
'reg' => $reg,
'vehicle_type' => $type,
'wash_subscription' => $subscription,
],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'POST',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
return $vehicle->asArray();
}
private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array
{
global $response;
$this->validateOptionalCustomerIdMatches($customerNumber);
$beforeState = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
if (self::isParametersSet(['type'])) {
$type = (int)self::getParameter('type');
self::requireType($type, self::type_int());
self::requireMinValue($type, 0);
if ($type === 0) {
$vehicle->type->set(0);
$vehicle->wash_subscription->set(0);
} else {
$product = new products_o();
$product->select($type);
if (!$product->exists() || !$product->subscription_allowed->value()) {
$response->error('Invalid type', 400);
}
$vehicle->type->set($type);
}
}
if (self::isParametersSet(['reg'])) {
$reg = (string)self::getParameter('reg');
self::requireType($reg, self::type_string());
self::requireMinLength('reg', 2);
self::requireMaxLength('reg', 12);
$vehicle->reg->set(preg_replace('/\s+/', '', $reg));
}
if (self::isParametersSet(['wash_subscription'])) {
$subscription = (bool)self::getParameter('wash_subscription');
self::requireType($subscription, self::type_bool());
if ((int)$vehicle->type->value() === 0 && $subscription) {
$response->error('Unable to set subscription, type is not set', 400);
}
$vehicle->wash_subscription->set($subscription ? 1 : 0);
}
if (self::isParametersSet(['reference'])) {
if (empty(self::getParameter('reference'))) {
$vehicle->reference->nullify();
} else {
$reference = (string)self::getParameter('reference');
self::requireType($reference, self::type_string());
self::requireMinLength('reference', 1);
self::requireMaxLength('reference', 255);
$vehicle->reference->set($reference);
}
}
$vehicle->objectChanged();
$afterState = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
$versionRelevantChange = (
(string)$beforeState['reg'] !== (string)$afterState['reg'] ||
(int)$beforeState['vehicle_type'] !== (int)$afterState['vehicle_type'] ||
(bool)$beforeState['wash_subscription'] !== (bool)$afterState['wash_subscription']
);
if ($versionRelevantChange) {
try {
$versioning = new economic_v2_versioning_service();
$effectiveAt = date('Y-m-d H:i:s');
if ((string)$beforeState['reg'] !== (string)$afterState['reg']) {
$versioning->closeActiveVehicleSubscriptionVersion(
(int)$beforeState['customer_number'],
(string)$beforeState['reg'],
$effectiveAt,
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
'reason' => 'identity_change',
]
);
}
$versioning->recordVehicleSubscriptionVersion(
$afterState,
$effectiveAt,
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
}
return $vehicle->asArray();
}
public function run(): void
{
$this->get('/vehicles', function () {
@@ -119,6 +424,93 @@ class vehiclesRoute
]
);
$this->get('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('list_vehicles_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$this->addUserScopedVehicleMeta($targetUser);
$response->success($this->listVehiclesForCustomer((int)$targetUser['customer_number']));
}, [
'list_vehicles_other' => 'List vehicles for a selected superuser customer account.',
]);
$this->get('/superuser/users/{user_id}/vehicles/summary', function () {
global $response;
$this->requirePermission('list_vehicles_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
}, [
'list_vehicles_other' => 'Summarize vehicles for a selected superuser customer account.',
]);
$this->post('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('add_vehicle_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->createVehicleForCustomer((int)$targetUser['customer_number']));
}, [
'add_vehicle_other' => 'Add a vehicle for a selected superuser customer account.',
]);
$this->put('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('edit_vehicle_other');
self::requireParameters(['id']);
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$vehicleId = (int)self::getParameter('id');
self::requireType($vehicleId, self::type_int());
self::requireMinValue($vehicleId, 1);
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
$response->add_meta('user_context', $targetUser);
$response->success($this->updateScopedVehicle($vehicle, (int)$targetUser['customer_number']));
}, [
'edit_vehicle_other' => 'Edit a vehicle for a selected superuser customer account.',
]);
$this->delete('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('delete_vehicle_other');
self::requireParameters(['id']);
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$vehicleId = (int)self::getParameter('id');
self::requireType($vehicleId, self::type_int());
self::requireMinValue($vehicleId, 1);
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
$beforeState = [
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
];
$vehicle->delete();
try {
(new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion(
(int)$beforeState['customer_number'],
(string)$beforeState['reg'],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'DELETE',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
$response->add_meta('user_context', $targetUser);
$response->success([
'success' => true,
'message' => 'Vehicle deleted successfully',
]);
}, [
'delete_vehicle_other' => 'Delete a vehicle for a selected superuser customer account.',
]);
$this->post('/vehicles', function () {
global $response;
$auth = new authentication();
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
usesApiSuite();
function wash_certificate_download_legacy_booking(int $customerNumber, int $departmentId, array $attributes = []): array
{
return api_fixtures()->createLegacyBooking(array_merge([
'customer_number' => $customerNumber,
'department' => $departmentId,
'washCertificateStatus' => 'pending',
'status' => 'pending',
], $attributes));
}
it('lets customer accounts reach their own wash certificate download without the download permission', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Customer Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$session['user']['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Wash certificate has not been issued yet');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('keeps customer wash certificate downloads scoped to their own bookings', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Wash Certificate Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Other Wash Certificate Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('lets customer accounts reach the legacy wash certificate pdf download gate', function (): void {
api_test_covers('GET /bookings/download_pdf', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Legacy PDF Other Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Legacy PDF Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->get(
'/bookings/download_pdf?id=' . (int)$booking['id'],
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
usesApiSuite();
function bulk_action_order_item_deleted_at(int $orderItemId): ?string
{
$row = api_test_runtime()->queryOne('SELECT deleted_at FROM order_items WHERE id = ' . $orderItemId . ' LIMIT 1');
return $row['deleted_at'] ?? null;
}
function bulk_action_order_invoice_collection_id(int $orderId): int
{
$row = api_test_runtime()->queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
return (int)($row['invoice_collection_id'] ?? 0);
}
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$product = api_fixtures()->createProduct([
'name' => 'Spot Free rinse',
'price' => 80,
]);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 80,
]);
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'locale' => 'da',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['preview_id'] ?? null)->toBeString()
->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft')
->and($preview['summary']['changed_count'] ?? null)->toBe(1)
->and($preview['order_items'][0]['order_item_id'] ?? null)->toBe((int)$orderItem['id'])
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekraeft',
'locale' => 'da',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
expect(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekræft',
'locale' => 'da',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$applied = $applyResponse->data();
expect($applied['preview'] ?? null)->toBeFalse()
->and($applied['result']['changed_count'] ?? null)->toBe(1)
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull();
});
it('previews and applies customer rule cleanup for both spotfree addon products', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup-spotfree-addons');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup-spotfree-addons');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Spotfree Addons Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$spotfreeVanProduct = api_fixtures()->createProduct([
'id' => 23,
'name' => 'Skylning med RO - Varevogn',
'category' => 4,
'price' => 39,
]);
$spotfreeTruckProduct = api_fixtures()->createProduct([
'id' => 24,
'name' => 'Skylning med RO - Lastbil',
'category' => 4,
'price' => 39,
]);
$vanOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeVanProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$truckOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeTruckProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
$previewOrderItemIds = array_map('intval', array_column($preview['order_items'] ?? [], 'order_item_id'));
sort($previewOrderItemIds);
expect($preview['summary']['changed_count'] ?? null)->toBe(2)
->and($previewOrderItemIds)->toBe([(int)$vanOrderItem['id'], (int)$truckOrderItem['id']])
->and(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->toBeNull();
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->not->toBeNull();
});
it('merges selected invoice collections into the explicit target after confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'merge');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'merge');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Merge Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$sourceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$targetOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
]);
$sourceOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['confirmation_phrase'] ?? null)->toBe('Confirm')
->and($preview['target_invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
->and($preview['summary']['orders_to_move'] ?? null)->toBe(1)
->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id']);
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id']);
});
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lets customer booking sessions read their own customer attributes', function (): void {
api_test_covers('GET /customer/attributes', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$attributes = array_map(
static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''),
is_array($response->data()) ? $response->data() : []
);
expect($attributes)->toContain('onlyTankCleaning');
expect($response->body)->not->toContain('list_customer_attributes');
});
it('keeps customer attribute reads scoped to the authenticated customer', function (): void {
api_test_covers('GET /customer/attributes', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Attribute Customer']);
api_fixtures()->addCustomerAttribute((int)$otherCustomer['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$otherCustomer['customer_number'],
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['list_customer_attributes']);
});
it('still lets attribute managers read another customer attributes', function (): void {
api_test_covers('GET /customer/attributes', 'permissions');
$session = api_fixtures()->createUserSession(['list_customer_attributes']);
$customer = api_fixtures()->createUser(['display_name' => 'Managed Attribute Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$attributes = array_map(
static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''),
is_array($response->data()) ? $response->data() : []
);
expect($attributes)->toContain('onlyTankCleaning');
});
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
use classes\limited_backoffice_service;
usesApiSuite();
function department_customer_pricing_price_insert(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
function department_customer_pricing_setup(array $departmentAttributes = []): array
{
$department = api_fixtures()->createDepartment([
'name' => 'Scoped Customer Pricing Department',
'custom_pricing_only' => 1,
...$departmentAttributes,
]);
$category = api_fixtures()->createCategory(['name' => 'Scoped Customer Pricing Category']);
$product = api_fixtures()->createProduct([
'name' => 'Scoped Customer Pricing Product',
'category' => $category['id'],
'price' => 1000,
'apply_category_discount' => 1,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
department_customer_pricing_price_insert((int)$department['id'], (int)$product['id'], 1000);
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Customer Pricing Customer']);
return [
'department' => $department,
'category' => $category,
'product' => $product,
'customer' => $customer,
];
}
it('rejects department customer pricing when custom-only pricing is disabled', function (): void {
api_test_covers('GET /superuser/department/customer-pricing', 'validation');
$fixture = department_customer_pricing_setup(['custom_pricing_only' => 0]);
$session = api_fixtures()->createUserSession(['superuser_fetch_department_customer_pricing']);
$response = api_client()->get(
'/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] .
'&user_id=' . (int)$fixture['customer']['id'],
$session['headers']
);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
expect($response->data()['code'] ?? null)->toBe('department_customer_pricing_disabled');
});
it('sets and applies department-specific customer discounts without legacy fallback', function (): void {
api_test_covers('GET /superuser/department/customer-pricing', 'happy');
api_test_covers('PUT /superuser/department/customer-pricing', 'happy');
api_test_covers('GET /products', 'pricing');
$fixture = department_customer_pricing_setup();
api_fixtures()->createPriceOverride([
'user_id' => $fixture['customer']['id'],
'is_category' => 0,
'product_or_category_id' => (string)$fixture['product']['id'],
'percentage' => 80,
]);
$session = api_fixtures()->createUserSession([
'superuser_fetch_department_customer_pricing',
'superuser_set_department_customer_pricing',
'list_products',
'department_access_' . (int)$fixture['department']['id'],
]);
$updated = api_client()->put('/superuser/department/customer-pricing', [
'department_id' => $fixture['department']['id'],
'user_id' => $fixture['customer']['id'],
'overrides' => [
[
'is_category' => false,
'product_or_category_id' => $fixture['product']['id'],
'discount' => 25,
'fixed_price' => null,
],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(25);
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(750);
$byCustomerNumber = api_client()->get(
'/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] .
'&customer_number=' . (int)$fixture['customer']['customer_number'],
$session['headers']
);
$byCustomerNumber
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($byCustomerNumber->data()['customer']['id'] ?? null)->toBe((int)$fixture['customer']['id']);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . (int)$fixture['product']['id'] .
'&department_id=' . (int)$fixture['department']['id'] .
'&customer_id=' . (int)$fixture['customer']['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($productResponse->data()['price'] ?? 0))->toBe(750);
});
it('limits department customer pricing to assigned limited-backoffice departments', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
$fixture = department_customer_pricing_setup();
$otherFixture = department_customer_pricing_setup(['name' => 'Denied Scoped Customer Pricing Department']);
$session = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
]);
api_client()
->get(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] .
'/customer-pricing?user_id=' . (int)$fixture['customer']['id'],
api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
'department_access_' . (int)$fixture['department']['id'],
])['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING]);
api_client()
->get(
'/limited-backoffice/departments/' . (int)$otherFixture['department']['id'] .
'/customer-pricing?user_id=' . (int)$otherFixture['customer']['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherFixture['department']['id']]);
api_client()
->put(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing',
[
'user_id' => $fixture['customer']['id'],
'overrides' => [],
],
api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
])['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING]);
$updated = api_client()->put(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing',
[
'user_id' => $fixture['customer']['id'],
'overrides' => [
[
'is_category' => true,
'product_or_category_id' => (string)$fixture['category']['id'],
'discount' => 30,
],
],
],
$session['headers']
);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(30);
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(700);
});
@@ -163,6 +163,39 @@ it('rejects department listing when the permission is missing', function (): voi
->assertMissingPermissions(['list_departments']);
});
it('returns superuser department custom pricing state as a boolean', function (): void {
$session = api_fixtures()->createUserSession(['superuser_fetch_department']);
$fallbackDepartment = api_fixtures()->createDepartment([
'name' => 'Fallback Pricing Department',
'custom_pricing_only' => 0,
]);
$customOnlyDepartment = api_fixtures()->createDepartment([
'name' => 'Custom Only Pricing Department',
'custom_pricing_only' => 1,
]);
$fallbackResponse = api_client()->get(
'/superuser/department?department_id=' . $fallbackDepartment['id'],
$session['headers']
);
$customOnlyResponse = api_client()->get(
'/superuser/department?department_id=' . $customOnlyDepartment['id'],
$session['headers']
);
$fallbackResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$customOnlyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($fallbackResponse->data()['custom_pricing_only'] ?? null)->toBeFalse();
expect($customOnlyResponse->data()['custom_pricing_only'] ?? null)->toBeTrue();
});
it('creates departments through the real endpoint', function (): void {
api_test_covers('POST /departments', 'happy');
@@ -163,6 +163,22 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
->toHaveKey('session_type', 'gateway-stream')
->toHaveKey('gateway_id', (int)$gateway['id']);
api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
[
'status' => 'connected',
'connection_id' => 'broker-presence-1',
'metadata' => [
'transport' => 'ws',
'refreshed_for' => 'shell-session',
],
],
edge_test_broker_headers()
)
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
['reason' => 'Broker shell validation'],
File diff suppressed because it is too large Load Diff
@@ -37,6 +37,18 @@ function order_booking_create_department(string $name): array
]);
}
function order_booking_create_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
}
it('lets customers create their own order bookings without booking permissions', function (): void {
api_test_covers('POST /order-bookings', 'auth');
@@ -65,6 +77,50 @@ it('lets customers create their own order bookings without booking permissions',
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('normalizes booking item prices from server-side customer and department pricing', function (): void {
api_test_covers('POST /order-bookings', 'pricing');
$session = api_fixtures()->createUserSession(['user']);
$department = order_booking_create_department('Own Booking Pricing Department');
$product = api_fixtures()->createProduct([
'name' => 'Booking Price Normalized Product',
'price' => 0,
'is_wash' => 0,
'display_in_booking_form' => 1,
]);
order_booking_create_department_price((int)$department['id'], (int)$product['id'], 425);
$payload = order_booking_create_payload($session['user'], $department, $product, 'PRICEFIX1');
$payload['items'][0]['price'] = 0;
$response = api_client()->post('/order-bookings', $payload, $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$responseItems = $response->data()['items'] ?? [];
expect($responseItems)
->toBeArray()
->and((int)($responseItems[0]['price'] ?? 0))->toBe(425);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
$storedItems = json_decode((string)($row['items'] ?? '[]'), true);
expect($storedItems)
->toBeArray()
->and((int)($storedItems[0]['price'] ?? 0))->toBe(425);
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => (int)$department['id'],
'product_id' => (int)$product['id'],
]);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('blocks subusers creating own customer order bookings without the bookings add node', function (): void {
api_test_covers('POST /order-bookings', 'auth');
@@ -150,7 +150,7 @@ it('uses a product fixed price instead of the best discount when adding an order
'cashier_id' => $cashier['id'],
'reference' => 'FIXED-PRICE',
]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products']);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products', 'department_access_' . $department['id']]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
@@ -281,7 +281,7 @@ it('does not allow clearing notes for order items whose product requires notes',
'quantity' => 1,
'notes' => 'Initial note',
]);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client()
->put('/order/items', [
@@ -73,7 +73,7 @@ it('creates orders through the orders endpoint', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Order Create Department']);
$session = api_fixtures()->createUserSession(['add_order']);
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
$response = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
@@ -128,7 +128,7 @@ it('defaults order PO only from a matching active booking', function (): void {
'po' => 'DELETED-BOOKING-PO',
'deleted_at' => date('Y-m-d H:i:s'),
]);
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [
$session = api_fixtures()->createUserSession(['add_order', 'edit_order', 'department_access_' . $department['id']], [
'customer_number' => $customer['customer_number'],
]);
@@ -149,7 +149,7 @@ it('defaults order PO only from a matching active booking', function (): void {
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [
$unauthorizedSession = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']], [
'customer_number' => $otherCustomer['customer_number'],
]);
$unauthorizedResponse = api_client()->post('/orders', [
@@ -233,7 +233,7 @@ it('rejects invalid order creation requests', function (): void {
$customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment();
$session = api_fixtures()->createUserSession(['add_order']);
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
@@ -262,7 +262,7 @@ it('updates orders through the primary and legacy endpoints', function (): void
'notes' => 'Before update',
'reg_1' => 'BEFORE1',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
$session = api_fixtures()->createUserSession(['edit_order', 'department_access_' . $department['id']]);
api_client()->put('/orders', [
'id' => $order['id'],
@@ -4,6 +4,23 @@ declare(strict_types=1);
usesApiSuite();
function products_api_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
it('treats null-like optional product params as omitted for product detail requests', function (): void {
api_test_covers('GET /products', 'optional-params');
@@ -30,20 +47,161 @@ it('treats null-like optional product params as omitted for product detail reque
expect($response->body)->not->toContain('department_access_0');
});
it('still requires department access when final product pricing uses a real department', function (): void {
api_test_covers('GET /products', 'permissions');
it('returns final department pricing without requiring list_products permission', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Department Priced Product',
'price' => 500,
]);
$session = api_fixtures()->createUserSession(['list_products']);
products_api_department_price((int)$department['id'], (int)$product['id'], 375);
$session = api_fixtures()->createUserSession(['user']);
api_client()->get(
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id'])
->toHaveKey('price', 375);
expect($response->body)->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions read own single-product final pricing with customer context', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Single Customer Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Single Customer Priced Product',
'price' => 0,
'display_in_booking_form' => 0,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 175);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id']
. '&customer_id=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id'])
->toHaveKey('price', 175);
expect($response->body)->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions list public products with final department pricing', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Customer Booking Products Department']);
$product = api_fixtures()->createProduct([
'name' => 'Customer Booking Visible Wash',
'price' => 700,
'is_wash' => 1,
'display_in_booking_form' => 1,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 650);
$hiddenProduct = api_fixtures()->createProduct([
'name' => 'Customer Booking Hidden Wash',
'price' => 900,
'is_wash' => 1,
'display_in_booking_form' => 0,
]);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?department_id=' . (int)$department['id'] . '&final_price=true',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$productsById = [];
foreach ($response->data() as $returnedProduct) {
$productsById[(int)($returnedProduct['id'] ?? 0)] = $returnedProduct;
}
expect($productsById)
->toHaveKey((int)$product['id'])
->and($productsById[(int)$product['id']]['name'] ?? null)->toBe('Customer Booking Visible Wash')
->and((int)($productsById[(int)$product['id']]['price'] ?? 0))->toBe(650)
->and($productsById[(int)$product['id']]['display_in_booking_form'] ?? null)->toBeTrue();
expect($productsById)->not->toHaveKey((int)$hiddenProduct['id']);
expect($response->body)
->not->toContain('list_products')
->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions with list_products read own final department pricing without department access', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$department = api_fixtures()->createDepartment(['name' => 'Own Customer Booking Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Own Customer Booking Wash',
'price' => 800,
'is_wash' => 1,
'display_in_booking_form' => 1,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 725);
$session = api_fixtures()->createUserSession(['user', 'list_products']);
$response = api_client()->get(
'/products?department_id=' . (int)$department['id']
. '&final_price=true'
. '&customer_id=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$productsById = [];
foreach ($response->data() as $returnedProduct) {
$productsById[(int)($returnedProduct['id'] ?? 0)] = $returnedProduct;
}
expect($productsById)
->toHaveKey((int)$product['id'])
->and((int)($productsById[(int)$product['id']]['price'] ?? 0))->toBe(725);
expect($response->body)
->not->toContain('department_access_' . (int)$department['id']);
});
it('still requires department access when list_products users request another customer final department pricing', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$department = api_fixtures()->createDepartment(['name' => 'Other Customer Pricing Department']);
$otherCustomer = api_fixtures()->createUser();
$session = api_fixtures()->createUserSession(['user', 'list_products']);
api_client()->get(
'/products?department_id=' . (int)$department['id']
. '&final_price=true'
. '&customer_id=' . (int)$otherCustomer['customer_number'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
@@ -51,6 +209,22 @@ it('still requires department access when final product pricing uses a real depa
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
it('prevents customer booking sessions from requesting another customer product pricing', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser();
api_client()->get(
'/products?final_price=true&customer_id=' . (int)$otherCustomer['customer_number'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['list_products']);
});
it('rejects invalid department ids without requesting department access zero', function (): void {
api_test_covers('GET /products', 'validation');
@@ -58,7 +232,7 @@ it('rejects invalid department ids without requesting department access zero', f
'name' => 'Invalid Department Product',
'price' => 600,
]);
$session = api_fixtures()->createUserSession(['list_products']);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lists limited backoffice permission templates for superuser role maintenance', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'happy');
$session = api_fixtures()->createUserSession([
'superuser',
'add_role_permission',
]);
$response = api_client()->get('/roles/limited-backoffice-permission-templates', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$templates = $response->data();
expect(array_column($templates, 'key'))->toBe([
'viewer',
'cashier',
'booking_coordinator',
'operations_lead',
'department_admin',
]);
$templatesByKey = array_column($templates, null, 'key');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_department_daily_reports');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_notifications');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('statistics_orders_new');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_access');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_prices_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_customer_pricing_view');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_customer_pricing_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_employees_manage');
});
it('requires superuser and role permission edit access for limited backoffice permission templates', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'auth');
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['add_role_permission'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser']);
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['superuser'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_role_permission']);
});
@@ -112,6 +112,32 @@ it('requires subuser management access before exposing permission nodes', functi
expect($authorized->data())->toBeArray()->not->toBeEmpty();
});
it('requires subuser management access before exposing simplified permission templates', function (): void {
api_test_covers('GET /subusers/permission-templates', 'auth');
api_test_covers('GET /subusers/permission-templates', 'happy');
$unauthenticated = api_client()->get('/subusers/permission-templates');
$unauthenticated
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false);
$session = api_fixtures()->createUserSession(['list_own_subusers']);
$authorized = api_client()->get('/subusers/permission-templates', $session['headers']);
$authorized
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($authorized->data()['templates'] ?? null)->toBeArray()->not->toBeEmpty();
expect(array_column($authorized->data()['templates'], 'key'))
->toContain('driver')
->toContain('booking_coordinator')
->toContain('fleet_admin')
->toContain('deactivated');
expect($authorized->data()['groups'] ?? null)->toBeArray()->not->toBeEmpty();
});
it('rejects customer subuser listing without own-scope permission', function (): void {
api_test_covers('GET /subusers', 'auth');
@@ -202,6 +228,11 @@ it('lists chauffeur grants across customers for superusers', function (): void {
(int)$secondCustomer['customer_number'],
['BOOKINGS_LIST']
);
$sharedGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$secondCustomer['customer_number'],
['ORDERS_LIST']
);
$response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']);
@@ -213,22 +244,26 @@ it('lists chauffeur grants across customers for superusers', function (): void {
$rows = array_values(array_filter(
is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item)
&& in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true)
&& in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true)
));
expect($rows)->toHaveCount(2);
$byGrantId = [];
$bySubuserId = [];
foreach ($rows as $row) {
$byGrantId[(int)$row['grant_id']] = $row;
$bySubuserId[(int)$row['id']] = $row;
}
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha');
expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']);
expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']);
expect($bySubuserId[(int)$firstSubuser['id']]['grant_count'])->toBe(2);
expect(array_column($bySubuserId[(int)$firstSubuser['id']]['grants'], 'grant_id'))
->toContain($firstGrantId)
->toContain($sharedGrantId);
expect($bySubuserId[(int)$firstSubuser['id']]['customer_numbers'])
->toContain((int)$firstCustomer['customer_number'])
->toContain((int)$secondCustomer['customer_number']);
expect($bySubuserId[(int)$secondSubuser['id']]['grant_count'])->toBe(1);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['grant_id'])->toBe($secondGrantId);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['customer_name'])->toBe('Fleet Customer Beta');
});
it('lets superusers invite chauffeurs for a selected customer', function (): void {
@@ -280,3 +315,275 @@ it('lets superusers invite chauffeurs for a selected customer', function (): voi
}
}
});
it('applies simplified driver access templates when inviting and updating chauffeur grants', function (): void {
api_test_covers('POST /superuser/subusers/invite', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'failure');
$session = api_fixtures()->createUserSession(['add_subusers', 'manage_subuser_grants']);
$customer = api_fixtures()->createUser(['display_name' => 'Template Target Customer']);
$phone = 72000000 + ((int)$customer['customer_number'] % 1000000);
$createdSubuserId = null;
$createdGrantId = null;
$setupToken = null;
try {
$invite = api_client()->post('/superuser/subusers/invite', [
'customer_number' => (int)$customer['customer_number'],
'name' => 'Template Driver',
'phone_country_code' => 45,
'phone' => $phone,
'permission_template_key' => 'booking_coordinator',
], $session['headers']);
$invite
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $invite->data();
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
expect($payload['subuser']['permission_template_key'] ?? null)->toBe('booking_coordinator');
expect($payload['subuser']['grant_permissions'] ?? null)
->toContain('BOOKINGS_EDIT')
->toContain('BOOKINGS_ADD')
->not->toContain('SUBUSERS_ADD');
$deactivate = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'deactivated'],
$session['headers']
);
$deactivate
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($deactivate->data()['subuser']['permission_template_key'] ?? null)->toBe('deactivated');
expect($deactivate->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($deactivate->data()['grant']['permissions'] ?? null)->toBe([]);
$invalid = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'unknown_template'],
$session['headers']
);
$invalid
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
} finally {
if ($setupToken !== null && $setupToken !== '') {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
if ($createdGrantId !== null) {
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
}
if ($createdSubuserId !== null) {
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
}
}
});
it('lists and summarizes chauffeurs through the user-scoped superuser route', function (): void {
$session = api_fixtures()->createUserSession(['list_subusers']);
$targetCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Alpha',
'economic_customer_name' => 'Scoped Customer Alpha',
]);
$otherCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Beta',
'economic_customer_name' => 'Scoped Customer Beta',
]);
$activeSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Active Driver']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Scoped Pending Driver',
'password_plaintext' => null,
]);
$disabledSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Disabled Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Other Driver']);
$activeGrantId = api_fixtures()->grantSubuser(
(int)$activeSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['BOOKINGS_LIST']
);
$disabledGrantId = api_fixtures()->grantSubuser(
(int)$disabledSubuser['id'],
(int)$targetCustomer['customer_number'],
['ORDERS_LIST']
);
api_test_runtime()->db()->query('UPDATE `subuser_grants` SET `enabled` = 0 WHERE `id` = ' . $disabledGrantId);
api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['SELFSERVE_LIST']
);
$response = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers?page=1&limit=20&include_non_enabled=true',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = is_array($response->data()) ? $response->data() : [];
$grantIds = array_map(static fn (array $row): int => (int)($row['grant_id'] ?? 0), $rows);
expect($grantIds)->toContain($activeGrantId);
expect($grantIds)->not->toContain(0);
expect($rows)->toHaveCount(3);
foreach ($rows as $row) {
expect($row['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number']);
}
expect($response->meta()['user_context']['user_id'] ?? null)->toBe((int)$targetCustomer['id']);
expect($response->meta()['subusers_summary'] ?? null)->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
expect($summary->data())->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
});
it('edits only customer-matching chauffeur grants through the user-scoped route', function (): void {
$session = api_fixtures()->createUserSession(['manage_subuser_grants']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']);
$targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']);
$targetGrantId = api_fixtures()->grantSubuser(
(int)$targetSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$otherGrantId = api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['BOOKINGS_LIST']
);
$update = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId,
[
'enabled' => false,
'note' => 'Scoped note',
'permissions' => ['ORDERS_LIST'],
],
$session['headers']
);
$crossCustomer = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId,
['note' => 'Should not save'],
$session['headers']
);
$update
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$crossCustomer
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
expect($update->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note');
expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']);
});
it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void {
$session = api_fixtures()->createUserSession(['add_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Other']);
$response = api_client()->post('/superuser/users/' . $targetCustomer['id'] . '/subusers/invite', [
'customer_number' => (int)$otherCustomer['customer_number'],
'name' => 'Mismatched Driver',
'phone_country_code' => 45,
'phone' => 71999999,
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Customer number does not match selected user');
});
it('resends pending user-scoped chauffeur invites and blocks accepted accounts', function (): void {
$session = api_fixtures()->createUserSession(['edit_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Resend Customer']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Pending Resend Driver',
'password_plaintext' => null,
]);
$acceptedSubuser = api_fixtures()->createSubuser(['name' => 'Accepted Resend Driver']);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$acceptedSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$pending = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $pendingSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$accepted = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $acceptedSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$pending
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$accepted
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
$token = $pending->data()['invite']['setup_token'] ?? null;
expect($token)->toBeString();
(new \objects\subusers_o())->invalidateSetupToken((string)$token);
});
@@ -4,6 +4,28 @@ declare(strict_types=1);
usesApiSuite();
function vehicles_without_customer_vehicles_deleted_at(callable $callback): void
{
$db = api_test_runtime()->db();
$column = $db->query("SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'");
if ($column === false) {
throw new RuntimeException('Unable to inspect customer_vehicles.deleted_at test column.');
}
$hadColumn = (int)$column->num_rows > 0;
if ($hadColumn) {
$db->query('ALTER TABLE `customer_vehicles` DROP COLUMN `deleted_at`');
}
try {
$callback();
} finally {
if ($hadColumn) {
$db->query('ALTER TABLE `customer_vehicles` ADD COLUMN `deleted_at` DATETIME NULL');
}
}
}
it('defaults wash subscriptions to false when a customer creates a vehicle without the field', function (): void {
api_test_covers('POST /vehicles', 'happy');
@@ -194,3 +216,249 @@ it('returns a null vehicle last_order_id when no order with items exists', funct
'last_order_id' => null,
]);
});
it('lists and summarizes vehicles through the user-scoped superuser route', function (): void {
api_test_covers('GET /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('GET /superuser/users/{user_id}/vehicles/summary', 'happy');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Other Vehicle Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDV1',
'wash_subscription' => 1,
'reference' => 'Scoped fleet',
]);
api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'OTHERV1',
'wash_subscription' => 1,
]);
$session = api_fixtures()->createUserSession(['list_vehicles_other']);
$response = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles?page=1&limit=20',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = $response->data();
expect(array_column($rows, 'reg'))->toContain('SCOPEDV1');
expect(array_column($rows, 'reg'))->not->toContain('OTHERV1');
expect($rows[0])->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDV1',
]);
expect($response->meta()['user_context'])->toMatchArray([
'user_id' => $targetUser['id'],
'customer_number' => $targetUser['customer_number'],
]);
expect($summary->data())->toMatchArray([
'total' => 1,
'wash_subscription' => 1,
'self_service' => 0,
]);
});
it('lists and summarizes user-scoped superuser vehicles when customer vehicles has no deleted at column', function (): void {
vehicles_without_customer_vehicles_deleted_at(function (): void {
api_test_covers('GET /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('GET /superuser/users/{user_id}/vehicles/summary', 'happy');
$customerVehiclesDeletedAtColumn = api_test_runtime()->db()->query(
"SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'"
);
expect($customerVehiclesDeletedAtColumn)->not->toBeFalse();
expect((int)$customerVehiclesDeletedAtColumn->num_rows)->toBe(0);
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Legacy Schema Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Other Vehicle Legacy Schema Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'LEGACYV1',
'wash_subscription' => 1,
'reference' => 'Legacy schema fleet',
]);
api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'LEGACYOTHER',
'wash_subscription' => 1,
]);
$session = api_fixtures()->createUserSession(['list_vehicles_other']);
$response = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles?page=1&limit=20',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = $response->data();
expect(array_column($rows, 'reg'))->toContain('LEGACYV1');
expect(array_column($rows, 'reg'))->not->toContain('LEGACYOTHER');
expect($rows[0])->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'LEGACYV1',
]);
expect($response->meta()['user_context'])->toMatchArray([
'user_id' => $targetUser['id'],
'customer_number' => $targetUser['customer_number'],
]);
expect($summary->data())->toMatchArray([
'total' => 1,
'wash_subscription' => 1,
'self_service' => 0,
]);
});
});
it('creates vehicles through the user-scoped superuser route and rejects mismatched customer ids', function (): void {
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'failure');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Create Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Mismatch Customer']);
$session = api_fixtures()->createUserSession(['add_vehicle_other']);
$created = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'type' => 53,
'reg' => 'SCOPEDADD',
'wash_subscription' => true,
'reference' => 'Created from user detail',
], $session['headers']);
$mismatch = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'BADSCOPED',
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$mismatch
->assertStatus(400)
->assertEnvelope()
->assertError();
$vehicleId = (int)($created->data()['id'] ?? 0);
expect($vehicleId)->toBeGreaterThan(0);
expect($created->data())->toMatchArray([
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDADD',
'wash_subscription' => true,
'reference' => 'Created from user detail',
]);
api_fixtures()->cleanupDeleteById('customer_vehicles', $vehicleId);
api_fixtures()->cleanupDeleteWhere('customer_vehicle_subscription_versions', ['vehicle_id' => $vehicleId]);
});
it('edits and deletes only matching customer vehicles through the user-scoped superuser route', function (): void {
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'failure');
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'failure');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Edit Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Guard Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDIT',
'wash_subscription' => 0,
]);
$otherVehicle = api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEBAD',
'wash_subscription' => 0,
]);
$deletableVehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDEL',
'wash_subscription' => 0,
]);
$session = api_fixtures()->createUserSession(['edit_vehicle_other', 'delete_vehicle_other']);
$edited = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $vehicle['id'],
'reg' => 'SCOPEDOK',
'reference' => 'Updated reference',
], $session['headers']);
$moved = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $vehicle['id'],
'customer_id' => $otherUser['customer_number'],
], $session['headers']);
$foreignEdit = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $otherVehicle['id'],
'reg' => 'SHOULDFAIL',
], $session['headers']);
$deleted = api_client()->delete(
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $deletableVehicle['id'],
null,
$session['headers']
);
$foreignDelete = api_client()->delete(
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $otherVehicle['id'],
null,
$session['headers']
);
$edited
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$moved
->assertStatus(400)
->assertEnvelope()
->assertError();
$foreignEdit
->assertStatus(404)
->assertEnvelope()
->assertError();
$deleted
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$foreignDelete
->assertStatus(404)
->assertEnvelope()
->assertError();
expect($edited->data())->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDOK',
'reference' => 'Updated reference',
]);
});
@@ -0,0 +1,267 @@
<?php
use classes\db;
use classes\department_wash_count_service;
function department_wash_count_integration_db(): db
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.');
}
$host = getenv('CONFIG_DB_HOST') ?: null;
$user = getenv('CONFIG_DB_USER') ?: null;
$password = getenv('CONFIG_DB_PASSWORD') ?: '';
$database = getenv('CONFIG_DB_DATABASE') ?: null;
if (!$host || !$user || !$database) {
test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.');
}
app_require('classes/db.php');
app_require('classes/department_wash_count_service.php');
$db = new db([
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => (int)(getenv('CONFIG_DB_PORT') ?: 3306),
]);
$db->connect();
$GLOBALS['db'] = $db;
department_wash_count_prepare_tables($db);
return $db;
}
function department_wash_count_prepare_tables(db $db): void
{
$db->query(
'CREATE TABLE IF NOT EXISTS department_lanes (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL,
name VARCHAR(255) NULL,
dynamic_image_id INT NULL,
selfserve_enabled TINYINT(1) NOT NULL DEFAULT 0
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS department_selfserve_conditions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL,
name VARCHAR(255) NULL,
product INT NULL,
action VARCHAR(64) NULL,
order_index INT NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS department_selfserve_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
condition_id INT NULL,
product INT NULL,
description TEXT NULL,
task VARCHAR(64) NULL,
buttons TEXT NULL,
order_index INT NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
price INT NOT NULL DEFAULT 0,
is_wash TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
cashier_id INT NOT NULL,
reference VARCHAR(255) NULL,
notes TEXT NULL,
department_id INT NOT NULL,
reg_1 VARCHAR(64) NULL,
reg_2 VARCHAR(64) NULL,
reg_3 VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
include_in_invoice TINYINT(1) NULL,
wash_id VARCHAR(255) NULL,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS order_items (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT NOT NULL,
product_id INT NOT NULL,
reference VARCHAR(255) NULL,
notes TEXT NULL,
cashier_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
quantity INT NOT NULL DEFAULT 1,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS selfserve_wash_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
lane_id INT NOT NULL,
department_id INT NOT NULL,
reg VARCHAR(64) NOT NULL,
status VARCHAR(64) NOT NULL,
allowed TINYINT(1) NOT NULL DEFAULT 0,
wash_started_at DATETIME NULL,
machine_start_triggered_at DATETIME NULL,
order_id INT NULL,
completed_at DATETIME NULL,
created_at DATETIME NULL,
deleted_at DATETIME NULL
)'
);
}
function department_wash_count_insert_product(db $db, string $suffix, bool $isWash): int
{
$productNameColumn = null;
if ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'name'")) > 0) {
$productNameColumn = 'name';
} elseif ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'title'")) > 0) {
$productNameColumn = 'title';
} else {
test()->markTestSkipped('Products table is missing both name and title columns required by this integration test.');
}
$escapedName = $db->escape_string('Wash count product ' . $suffix . ' ' . ($isWash ? 'wash' : 'minute'));
$descriptionSql = $db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'description'")) > 0
? ', description'
: '';
$descriptionValueSql = $descriptionSql !== '' ? ", 'Integration wash count product'" : '';
$db->query(
"INSERT INTO products ($productNameColumn$descriptionSql, is_wash)
VALUES ('$escapedName'$descriptionValueSql, " . ($isWash ? '1' : '0') . ')'
);
return (int)$db->insert_id();
}
function department_wash_count_insert_order(db $db, int $departmentId, int $productId, string $reference, string $createdAt, int $price = 100): int
{
$escapedReference = $db->escape_string($reference);
$escapedCreatedAt = $db->escape_string($createdAt);
$db->query(
"INSERT INTO orders (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, created_at)
VALUES (1, 1, '$escapedReference', 'Integration test', $departmentId, 'COUNT$departmentId', '', '', '$escapedCreatedAt')"
);
$orderId = (int)$db->insert_id();
$db->query(
"INSERT INTO order_items (order_id, product_id, reference, notes, cashier_id, price, quantity)
VALUES ($orderId, $productId, '$escapedReference', 'Integration test', 1, $price, 1)"
);
return $orderId;
}
function department_wash_count_insert_session(
db $db,
int $departmentId,
string $reg,
string $status,
?string $completedAt,
?int $orderId = null
): int {
$escapedReg = $db->escape_string($reg);
$escapedStatus = $db->escape_string($status);
$completedAtSql = $completedAt === null ? 'NULL' : "'" . $db->escape_string($completedAt) . "'";
$orderIdSql = $orderId === null ? 'NULL' : (string)$orderId;
$db->query(
"INSERT INTO selfserve_wash_sessions (lane_id, department_id, reg, status, allowed, wash_started_at, machine_start_triggered_at, order_id, completed_at, created_at)
VALUES (1, $departmentId, '$escapedReg', '$escapedStatus', 1, NULL, NULL, $orderIdSql, $completedAtSql, $completedAtSql)"
);
return (int)$db->insert_id();
}
it('counts completed self-serve minute billing sessions site-wide and dedupes linked wash orders', function (): void {
$db = department_wash_count_integration_db();
$service = new department_wash_count_service();
$suffix = (string)random_int(10000, 99999);
$departmentId = 700000 + (int)$suffix;
$orderIds = [];
$productIds = [];
$sessionIds = [];
try {
$washProductId = department_wash_count_insert_product($db, $suffix, true);
$minuteProductId = department_wash_count_insert_product($db, $suffix, false);
$productIds = [$washProductId, $minuteProductId];
$orderIds[] = department_wash_count_insert_order($db, $departmentId, $washProductId, 'plain-wash-' . $suffix, '2026-04-01 10:15:00', 100);
$linkedMinuteOrderId = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'linked-minute-' . $suffix, '2026-04-01 11:10:00', 25);
$orderIds[] = $linkedMinuteOrderId;
$linkedWashOrderId = department_wash_count_insert_order($db, $departmentId, $washProductId, 'linked-wash-' . $suffix, '2026-04-01 13:05:00', 100);
$orderIds[] = $linkedWashOrderId;
$orderIds[] = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'plain-minute-' . $suffix, '2026-04-01 14:00:00', 25);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKMIN' . $suffix, 'COMPLETED', '2026-04-01 12:00:00', $linkedMinuteOrderId);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'STAND' . $suffix, 'COMPLETED', '2026-04-01 12:20:00');
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKWASH' . $suffix, 'COMPLETED', '2026-04-01 13:30:00', $linkedWashOrderId);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'FORCE' . $suffix, 'FORCE_STOPPED', '2026-04-01 15:00:00');
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'OPEN' . $suffix, 'COMPLETED', null);
expect($service->countInDateRange('2026-04-01 00:00:00', '2026-04-01 23:59:59', $departmentId))->toBe(4);
$hourRows = $service->countByHourForDepartments('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]);
$countsByHour = [];
foreach ($hourRows as $row) {
$countsByHour[$row['hour_bucket']] = $row['wash_count'];
}
expect($countsByHour)->toMatchArray([
'2026-04-01 10:00:00' => 1,
'2026-04-01 11:00:00' => 1,
'2026-04-01 12:00:00' => 1,
'2026-04-01 13:00:00' => 1,
]);
expect($countsByHour)->not->toHaveKey('2026-04-01 14:00:00')
->and($countsByHour)->not->toHaveKey('2026-04-01 15:00:00');
$summary = $service->transactionSummary('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]);
expect($summary)->toMatchArray([
'quantity' => 4,
'products' => 4,
'earnings' => 250,
'washes' => 4,
]);
} finally {
if ($sessionIds !== []) {
$db->query('DELETE FROM selfserve_wash_sessions WHERE id IN (' . implode(',', array_map('intval', $sessionIds)) . ')');
}
if ($orderIds !== []) {
$orderIdsSql = implode(',', array_map('intval', $orderIds));
$db->query("DELETE FROM order_items WHERE order_id IN ($orderIdsSql)");
$db->query("DELETE FROM orders WHERE id IN ($orderIdsSql)");
}
if ($productIds !== []) {
$db->query('DELETE FROM products WHERE id IN (' . implode(',', array_map('intval', $productIds)) . ')');
}
$db->close();
}
});
@@ -358,6 +358,22 @@ it('persists gateway cutover relay bindings used by self-serve Shelly dispatch',
->toHaveKey('fallback_mode', edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL)
->and($context['manager']->getDepartmentTransportMode((int)$department['id']))
->toBe(edge_gateway_manager::TRANSPORT_MODE_GATEWAY);
$context['manager']->setRelayBindings($gatewayId, [], (int)$user['id']);
$recreatedBindings = $context['manager']->setRelayBindings($gatewayId, [[
'relay_id' => 'relay-machine',
'device_id' => 'device-machine-recreated',
'local_ip' => '10.50.60.71',
'channel' => 1,
]], (int)$user['id']);
expect($recreatedBindings)->toHaveCount(1)
->and($recreatedBindings[0])->toMatchArray([
'relay_id' => 'relay-machine',
'device_id' => 'device-machine-recreated',
'local_ip' => '10.50.60.71',
'channel' => 1,
]);
} finally {
$context['cleanup']->run();
}
@@ -1,11 +1,8 @@
<?php
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_cache.php');
app_require('classes/system_search_openai_intent_parser.php');
use classes\system_search_cache;
use classes\system_search_openai_intent_parser;
if (!class_exists('SystemSearchTestRedisAdapter')) {
class SystemSearchTestRedisAdapter
@@ -72,76 +69,24 @@ afterEach(function (): void {
system_search_cache::setAdapterForTests(null);
});
it('reuses parser cache entries for repeated natural-language intent requests', function (): void {
$calls = 0;
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey) use (&$calls): array {
$calls++;
return [
'output' => [
[
'content' => [
[
'text' => json_encode([
'success' => true,
'normalized_query' => 'acme unpaid invoices',
'aliases' => ['acme'],
'entity_hints' => ['invoices'],
'confidence' => 0.91,
'association_hint' => true,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
it('stores and clears query cache entries in the active system search namespace', function (): void {
$hash = md5('test-query');
$payload = [
'results' => [],
'grouped_results' => [],
'meta' => [
'query' => 'acme',
'max_results' => 50,
'returned' => 0,
'truncated' => false,
],
];
$first = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']);
$second = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']);
system_search_cache::setQuery($hash, $payload, 120);
expect(system_search_cache::getQuery($hash))->toBe($payload);
expect($first['source'])->toBe('openai');
expect($second['source'])->toBe('cache');
expect($calls)->toBe(1);
});
it('clears parser cache namespace via clearAll to force a fresh parse', function (): void {
$calls = 0;
$parser = new system_search_openai_intent_parser(
function (array $payload, string $apiKey) use (&$calls): array {
$calls++;
return [
'output' => [
[
'content' => [
[
'text' => json_encode([
'success' => true,
'normalized_query' => 'acme invoices',
'aliases' => ['acme'],
'entity_hints' => ['invoices'],
'confidence' => 0.8,
'association_hint' => false,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
$parser->parse('acme invoices', ['invoices']);
system_search_cache::clearAll();
$result = $parser->parse('acme invoices', ['invoices']);
expect($result['source'])->toBe('openai');
expect($calls)->toBe(2);
expect(system_search_cache::getQuery($hash))->toBeNull();
});
it('bumps per-table cache versions when a dirty-table marker is registered', function (): void {
@@ -838,6 +838,54 @@ final class ApiFixtures
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createLegacyBooking(array $attributes): array
{
$customerNumber = (int)($attributes['customer_number'] ?? 0);
$departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0);
if ($customerNumber <= 0 || $departmentId <= 0) {
throw new RuntimeException('Legacy bookings require customer_number and department.');
}
$bookingId = $this->insertRowWithExistingColumns('bookings', [
'customer_number' => $customerNumber,
'wash_type' => (string)($attributes['wash_type'] ?? 'API wash'),
'contact_email' => (string)($attributes['contact_email'] ?? 'customer@example.test'),
'reference_number' => (string)($attributes['reference_number'] ?? 'API-LEGACY-BOOKING'),
'regNrTraekker' => (string)($attributes['regNrTraekker'] ?? 'LEG123'),
'regNrTrailer' => (string)($attributes['regNrTrailer'] ?? ''),
'washCertificateEmail' => (string)($attributes['washCertificateEmail'] ?? ''),
'date' => $attributes['date'] ?? $this->now(),
'department' => $departmentId,
'pickup_bool' => (int)($attributes['pickup_bool'] ?? 0),
'notes' => (string)($attributes['notes'] ?? ''),
'washCertificateStatus' => (string)($attributes['washCertificateStatus'] ?? 'pending'),
'washCertificateUrl' => (string)($attributes['washCertificateUrl'] ?? ''),
'wash_certificate_pdf' => $attributes['wash_certificate_pdf'] ?? null,
'status' => (string)($attributes['status'] ?? 'pending'),
'data' => json_encode($attributes['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'virtual_cart' => (int)($attributes['virtual_cart'] ?? 0),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(function () use ($bookingId): void {
$this->deleteById('bookings', $bookingId);
$this->deleteRedisKey('bookings_' . $bookingId . '_asArray');
$this->deleteRedisPattern('bookings:*');
});
return [
'id' => $bookingId,
'customer_number' => $customerNumber,
'department' => $departmentId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
@@ -1789,6 +1837,7 @@ final class ApiFixtures
$this->deleteWhereIfPossible('tokens', ['user_id' => $userId]);
$this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]);
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
$this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]);
$this->deleteWhereIfPossible('limited_backoffice_employees', ['user_id' => $userId]);
$this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]);
@@ -1803,6 +1852,7 @@ final class ApiFixtures
'object_type' => 'users',
'object_id' => $userId,
]);
$this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]);
@@ -48,6 +48,11 @@ final class ApiResponse
return $this;
}
public function assertError(): self
{
return $this->assertSuccess(false);
}
public function assertMessage(string $expectedMessage): self
{
$this->assertEnvelope();
@@ -78,6 +78,23 @@ CREATE TABLE IF NOT EXISTS `groups_permissions` (
KEY `idx_groups_permissions_group_id` (`group_id`),
KEY `idx_groups_permissions_permission` (`permission`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'logs' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `logs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`module` VARCHAR(191) NOT NULL,
`department` VARCHAR(191) NULL,
`type` INT NOT NULL,
`user_id` INT NULL,
`action` VARCHAR(191) NOT NULL,
`message` TEXT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_logs_module` (`module`),
KEY `idx_logs_action` (`action`),
KEY `idx_logs_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'departments' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `departments` (
@@ -344,7 +361,9 @@ CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` (
PRIMARY KEY (`id`),
KEY `idx_selfserve_wash_sessions_lane_reg` (`lane_id`, `reg`),
KEY `idx_selfserve_wash_sessions_status` (`status`),
KEY `idx_selfserve_wash_sessions_customer` (`customer_number`)
KEY `idx_selfserve_wash_sessions_customer` (`customer_number`),
KEY `idx_selfserve_wash_sessions_department_completed` (`department_id`, `completed_at`),
KEY `idx_selfserve_wash_sessions_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'selfserve_wash_session_answers' => <<<'SQL'
@@ -536,6 +555,35 @@ CREATE TABLE IF NOT EXISTS `orders` (
KEY `idx_orders_period_customer_created_deleted` (`customer_id`, `created_at`, `deleted_at`),
KEY `idx_orders_period_created_deleted_customer` (`created_at`, `deleted_at`, `customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'bookings' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `bookings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_number` INT NOT NULL,
`wash_type` VARCHAR(255) NULL,
`contact_email` VARCHAR(255) NULL,
`reference_number` VARCHAR(255) NULL,
`regNrTraekker` VARCHAR(32) NULL,
`regNrTrailer` VARCHAR(32) NULL,
`washCertificateEmail` VARCHAR(255) NULL,
`date` DATETIME NULL,
`department` INT NOT NULL DEFAULT 0,
`pickup_bool` TINYINT(1) NOT NULL DEFAULT 0,
`notes` TEXT NULL,
`washCertificateStatus` VARCHAR(32) NULL,
`washCertificateUrl` TEXT NULL,
`wash_certificate_pdf` VARCHAR(255) NULL,
`status` VARCHAR(32) NULL,
`data` LONGTEXT NULL,
`virtual_cart` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_bookings_customer_number` (`customer_number`),
KEY `idx_bookings_department` (`department`),
KEY `idx_bookings_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'order_bookings' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `order_bookings` (
@@ -781,6 +829,23 @@ CREATE TABLE IF NOT EXISTS `price_overrides` (
KEY `idx_price_overrides_user_id` (`user_id`),
KEY `idx_price_overrides_lookup` (`user_id`, `is_category`, `product_or_category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_customer_price_overrides' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`user_id` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'products_options' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `products_options` (
@@ -14,6 +14,10 @@ class ApiTestCase extends TestCase
$skipReason = \api_test_runtime()->skipReason();
if ($skipReason !== null) {
if (getenv('API_TEST_FAIL_ON_SKIP') === '1') {
throw new \RuntimeException($skipReason);
}
$this->markTestSkipped($skipReason);
}
@@ -52,6 +52,17 @@ function assert_api_envelope(ApiResponse $response): ApiResponse
function edge_test_broker_secret(): string
{
try {
$configured = api_test_runtime()->queryOne(
"SELECT `value` FROM `module_config` WHERE `module` = 'edgegateway' AND `variable` = 'broker_shared_secret' LIMIT 1"
);
$secret = trim((string)($configured['value'] ?? ''));
if ($secret !== '') {
return $secret;
}
} catch (\Throwable) {
}
$secret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
return $secret !== '' ? $secret : 'truckwash-edge-test-secret';
@@ -23,9 +23,14 @@ $commonEnv = [
'REDIS_CONFIG_DEBUG_HOST' => 'redis',
'REDIS_CONFIG_DEBUG_PORT' => '6379',
'REDIS_CONFIG_DEBUG_DATABASE' => '0',
'EDGE_BROKER_URL' => 'http://edge-broker:4300',
'EDGE_PUBLIC_BROKER_URL' => '',
'EDGE_BROKER_SHARED_SECRET' => 'truckwash-edge-ci',
'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0',
'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1',
'MINIO_ENDPOINT' => '',
'MINIO_ACCESS_KEY' => '',
'MINIO_SECRET_KEY' => '',
];
foreach ($commonEnv as $key => $value) {
@@ -42,13 +47,31 @@ $commands = [
'RUN_INTEGRATION_TESTS=1 vendor/bin/pest --testsuite=Integration --colors=always',
],
'api' => [
'RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Api --colors=always',
'RUN_API_TESTS=1 API_TEST_FAIL_ON_SKIP=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Api --colors=always',
],
'legacy' => [
'RUN_LEGACY_TESTS=1 RUN_INTEGRATION_TESTS=1 RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Legacy --colors=always',
'RUN_LEGACY_TESTS=1 RUN_INTEGRATION_TESTS=1 RUN_API_TESTS=1 API_TEST_FAIL_ON_SKIP=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Legacy --colors=always',
],
];
/**
* @param array<int, string> $extensions
*/
function assert_required_php_extensions(array $extensions): void
{
$missing = array_values(array_filter(
$extensions,
static fn (string $extension): bool => !extension_loaded($extension)
));
if ($missing === []) {
return;
}
fwrite(STDERR, 'Missing required PHP extension(s): ' . implode(', ', $missing) . PHP_EOL);
exit(1);
}
function reset_ci_state(): void
{
$database = getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug';
@@ -86,6 +109,10 @@ function reset_ci_state(): void
if ($suite === 'all') {
foreach (['unit', 'integration', 'api', 'legacy'] as $selectedSuite) {
if (in_array($selectedSuite, ['api', 'legacy'], true)) {
assert_required_php_extensions(['mysqli']);
}
reset_ci_state();
foreach ($commands[$selectedSuite] as $command) {
passthru($command, $exitCode);
@@ -102,6 +129,10 @@ if ($suite === 'all') {
exit(2);
}
if (in_array($suite, ['api', 'legacy'], true)) {
assert_required_php_extensions(['mysqli']);
}
foreach ($selectedCommands as $command) {
passthru($command, $exitCode);
if ($exitCode !== 0) {
@@ -0,0 +1,19 @@
<?php
it('delegates automatic cron execution to the db-backed scheduler', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('CRON_LOAD_LEGACY_FUNCTIONS_ONLY');
expect($content)->toContain("new \\classes\\cron_scheduler())->runDue('automatic')");
expect($content)->toContain('function EconomicTransferQueueCron(): void');
expect($content)->toContain('function GoalsProgressAlertsCron(): void');
});
it('only rewrites htaccess when the compatibility file changes', function (): void {
$content = file_get_contents(app_path('cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('file_get_contents($htaccessPath) !== $htaccess');
expect($content)->toContain('file_put_contents($htaccessPath, $htaccess)');
});
@@ -0,0 +1,31 @@
<?php
it('exposes superuser cron status, history, manual run, and config routes', function (): void {
$content = file_get_contents(app_path('routes/cronRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/cron');
expect($content)->toContain('/superuser/cron/runs');
expect($content)->toContain('/superuser/cron/run');
expect($content)->toContain('/superuser/cron/config');
expect($content)->toContain('new cron_scheduler()');
expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_view')");
expect($content)->toContain("requireClassicSuperuserPermission('SUPERUSER_RUN_CRON')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_manage')");
});
it('does not include caller-controlled cron php files', function (): void {
$content = file_get_contents(app_path('routes/cronRoute.php'));
expect($content)->not->toContain("WD . '/cron/'");
expect($content)->not->toContain("file_exists(WD . '/cron/'");
expect($content)->not->toContain("require_once WD . '/cron/'");
});
it('keeps the legacy cron post endpoint as a scheduler bridge', function (): void {
$content = file_get_contents(app_path('routes/cronRoute.php'));
expect($content)->toContain('runTask($job, \'manual\'');
expect($content)->toContain("runDue('manual')");
expect($content)->toContain('Cron job ran successfully');
});
@@ -0,0 +1,57 @@
<?php
use classes\cron_schedule;
use classes\cron_task_registry;
it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions();
expect($definitions)->toHaveCount(20);
expect(array_keys($definitions))->toContain(
'system.sync_logs',
'economic.transfer_queue',
'dynamicimages.pre_render',
'weatherapi.preload_department_responses',
'goals.progress_alerts',
'selfserve.activate_opening_cleaner_relays'
);
$transferQueue = $registry->get('EconomicTransferQueueCron');
expect($transferQueue)->not->toBeNull();
expect($transferQueue->id)->toBe('economic.transfer_queue');
expect($transferQueue->module)->toBe('economic');
expect($transferQueue->schedule)->toBe(['type' => 'interval', 'seconds' => 30]);
});
it('keeps every discovered cron task in a module cron folder', function (): void {
$files = glob(app_path('modules/*/cron/tasks.php')) ?: [];
$modules = array_map(
static fn(string $file): string => basename(dirname(dirname($file))),
$files
);
$registry = new cron_task_registry(app_path('modules'));
foreach ($registry->definitions() as $definition) {
expect($modules)->toContain($definition->module);
}
});
it('normalizes and advances interval schedules without tight loops', function (): void {
expect(cron_schedule::normalize(['type' => 'interval', 'seconds' => 60]))
->toBe(['type' => 'interval', 'seconds' => 60]);
$now = strtotime('2026-07-09 12:10:00');
$next = cron_schedule::nextRunAt(
['type' => 'interval', 'seconds' => 300],
'2026-07-09 12:00:00',
$now
);
expect($next)->toBe('2026-07-09 12:15:00');
});
it('rejects unsafe cron intervals', function (): void {
expect(fn() => cron_schedule::normalize(['type' => 'interval', 'seconds' => 5]))
->toThrow(InvalidArgumentException::class);
});
@@ -392,5 +392,8 @@ it('wires the overview route to batched repository methods and overview path', f
expect($routeContent)->toContain('/departments/daily-reports/outside-hours-trend');
expect($routeContent)->toContain('getTransactionSummaryForDepartments');
expect($routeContent)->toContain('normalizeDepartmentIdsParameter');
expect($objectContent)->toContain('department_wash_count_service');
expect($objectContent)->toContain('countInDateRange');
expect($objectContent)->toContain('listTransactions');
expect($objectContent)->toContain('public function getBookingSummaryForDepartments');
});
@@ -0,0 +1,138 @@
<?php
app_require('classes/department_wash_count_service.php');
use classes\department_wash_count_service;
final class DepartmentWashCountFakeResult
{
public int $num_rows;
private int $cursor = 0;
public function __construct(private readonly array $rows)
{
$this->num_rows = count($rows);
}
public function fetch_assoc(): ?array
{
if (!array_key_exists($this->cursor, $this->rows)) {
return null;
}
return $this->rows[$this->cursor++];
}
}
final class DepartmentWashCountFakeDb
{
public array $queries = [];
public function escape_string(string $value): string
{
return addslashes($value);
}
public function getDatabase(): string
{
return 'test_db';
}
public function query(string $sql): DepartmentWashCountFakeResult
{
$this->queries[] = $sql;
if (str_contains($sql, 'information_schema.COLUMNS') || str_contains($sql, 'information_schema.STATISTICS')) {
return new DepartmentWashCountFakeResult([['c' => 1, 'DATA_TYPE' => 'text']]);
}
if (str_contains($sql, 'COUNT(DISTINCT o.id) AS quantity')) {
return new DepartmentWashCountFakeResult([[
'quantity' => 3,
'products' => 5,
'earnings' => 250,
]]);
}
return new DepartmentWashCountFakeResult([[
'department_id' => 7,
'hour_bucket' => '2026-04-01 12:00:00',
'wash_count' => 2,
]]);
}
}
function department_wash_count_find_query(array $queries, string $needle): string
{
foreach ($queries as $query) {
if (str_contains($query, $needle)) {
return $query;
}
}
return '';
}
it('builds wash counts from order washes and completed self-serve sessions with linked-order dedupe', function (): void {
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$fakeDb = new DepartmentWashCountFakeDb();
$GLOBALS['db'] = $fakeDb;
try {
$service = new department_wash_count_service();
$rows = $service->countByHourForDepartments('2026-04-01 00:00:00', '2026-04-01 23:59:59', [7]);
expect($rows)->toBe([[
'department_id' => 7,
'hour_bucket' => '2026-04-01 12:00:00',
'wash_count' => 2,
]]);
$query = department_wash_count_find_query($fakeDb->queries, 'FROM selfserve_wash_sessions s');
expect($query)->toContain('FROM selfserve_wash_sessions s')
->and($query)->toContain("UPPER(TRIM(s.status)) = 'COMPLETED'")
->and($query)->toContain('s.completed_at IS NOT NULL')
->and($query)->toContain('COALESCE(linked_o.created_at, s.completed_at)')
->and($query)->toContain("CONCAT('order:', linked_o.id)")
->and($query)->toContain('GROUP BY dedupe_key, department_id');
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('keeps transaction totals order-based while using expanded wash counts', function (): void {
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$fakeDb = new DepartmentWashCountFakeDb();
$GLOBALS['db'] = $fakeDb;
try {
$service = new department_wash_count_service();
$summary = $service->transactionSummary('2026-04-01 00:00:00', '2026-04-01 23:59:59', [7]);
expect($summary)->toBe([
'quantity' => 3,
'products' => 5,
'earnings' => 250,
'washes' => 2,
]);
$summaryQuery = department_wash_count_find_query($fakeDb->queries, 'COUNT(DISTINCT o.id) AS quantity');
$countQuery = department_wash_count_find_query($fakeDb->queries, 'FROM selfserve_wash_sessions s');
expect($summaryQuery)->toContain('COUNT(DISTINCT o.id) AS quantity')
->and($summaryQuery)->not->toContain('selfserve_wash_sessions')
->and($countQuery)->toContain('selfserve_wash_sessions');
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
@@ -26,7 +26,7 @@ if (!class_exists('FakeEconomicV2BookedDepartment75VersioningService')) {
return $this->subscriptionVersions;
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -27,7 +27,7 @@ if (!class_exists('FakeEconomicV2DistributionVersioningService')) {
return $this->subscriptionVersions;
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -97,7 +97,7 @@ if (!class_exists('TestableEconomicV2DistributionService')) {
return 100.0;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -23,7 +23,7 @@ if (!class_exists('EconomicV2DistributionServiceOrderOverrideVersioningDouble'))
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
return null;
}
@@ -23,7 +23,7 @@ if (!class_exists('FakeEconomicV2ProductFixedPriceVersioningService')) {
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp, ?int $department_id = null): ?array
{
if (!$is_category && (int)$object_id === 42) {
return [
@@ -99,6 +99,21 @@ if (!class_exists('TestableEconomicV2ProductFixedPriceDistributionService')) {
return 1000.0;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
if ($product_id !== 42) {
return null;
}
return [
'customer_number' => $customer_number,
'is_category' => 0,
'object_id' => '42',
'discount' => 10,
'fixed_price' => 350,
];
}
protected function isOrderEligible(array $order): bool
{
return true;
@@ -41,5 +41,5 @@ it('anchors historical resolution on order created_at timestamps in distribution
expect($content)->not->toBeFalse();
expect($content)->toContain('resolveFixedPricingVersionAt($customer_number, $created_at)');
expect($content)->toContain('resolveVehicleSubscriptionVersionsAt($customer_number, $created_at)');
expect($content)->toContain('resolveDiscountForProduct($customer_number, $product_id, $created_at)');
expect($content)->toContain('resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id)');
});

Some files were not shown because too many files have changed in this diff Show More