Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc22976873 | ||
|
|
7a1c444df0 | ||
|
|
6a00f023b1 | ||
|
|
8aefbd8fb3 | ||
|
|
a7181a4ab2 | ||
|
|
fc6c76ad1b | ||
|
|
6a694f92cc | ||
|
|
b7a2dc04d7 | ||
|
|
870b88e707 | ||
|
|
e14cddc1fb | ||
|
|
31887fa8c9 | ||
|
|
eac83b18a0 | ||
|
|
3817a37021 | ||
|
|
940a3e5e9b | ||
|
|
3221223865 | ||
|
|
b51006d9d1 | ||
|
|
6b7592921d | ||
|
|
f26a427510 | ||
|
|
6de747252f | ||
|
|
ff225ff5e7 | ||
|
|
dcef993f12 | ||
|
|
23aca449f7 | ||
|
|
31b5ba136a | ||
|
|
f0b5479f30 | ||
|
|
b77efc538a | ||
|
|
10d1eb5bac | ||
|
|
084435e9b8 | ||
|
|
172a21c517 | ||
|
|
c24428e4c7 | ||
|
|
bf1d6a583e | ||
|
|
08ac16e665 | ||
|
|
79185a3c76 | ||
|
|
3a730e3507 | ||
|
|
ce43c4e064 | ||
|
|
579ddcf510 | ||
|
|
0b342a7780 | ||
|
|
57bcbaf72a | ||
|
|
d9fbba3130 | ||
|
|
e4465d9d91 | ||
|
|
734cd13c87 | ||
|
|
d0f94ac549 | ||
|
|
1d25cbe21c | ||
|
|
53d0636193 | ||
|
|
04bb26f1b0 | ||
|
|
df0d4783d0 | ||
|
|
39c06ceab6 | ||
|
|
7dd428d18e | ||
|
|
0103a40156 | ||
|
|
e208b1b2a4 | ||
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
ed2736e528 | ||
|
|
c7f5c73a9e | ||
|
|
c10af48954 | ||
|
|
7ac5c5585b | ||
|
|
8544ce0a18 | ||
|
|
614715822f | ||
|
|
1da02e2486 | ||
|
|
742b15116d | ||
|
|
e0ae74bdc2 | ||
|
|
08dc803b3e | ||
|
|
248a901f24 | ||
|
|
8bbdf9daf5 | ||
|
|
c089186046 | ||
|
|
2ae1fc3fcf | ||
|
|
d9eacf6f84 | ||
|
|
f262047476 | ||
|
|
b8390ac0d3 | ||
|
|
0d4a5470e5 | ||
|
|
845ca6e48e | ||
|
|
1cda2a81aa | ||
|
|
8e46ce1b04 | ||
|
|
11c2a1b72e | ||
|
|
62f2c80dda | ||
|
|
430c90cbca | ||
|
|
f02dfd8c9c | ||
|
|
db1b9a2c96 | ||
|
|
84dec4c0a2 | ||
|
|
d47ea1d659 |
+931
-77
File diff suppressed because it is too large
Load Diff
@@ -928,22 +928,20 @@ async function main() {
|
||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||
);
|
||||
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
|
||||
assert.ok(
|
||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||
"Gateway logs page did not persist the shell transcript."
|
||||
);
|
||||
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
assert.ok(
|
||||
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||
"Gateway logs page did not include the shell close audit event."
|
||||
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
|
||||
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
|
||||
},
|
||||
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
|
||||
);
|
||||
|
||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,10 @@ class customer_mass_import_service
|
||||
if ($cvrLength < 8 || $cvrLength > 20) {
|
||||
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
|
||||
}
|
||||
|
||||
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
|
||||
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizePositiveInt(mixed $value): ?int
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
if ($message !== null) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$context = self::loadOrderProductContext($orderId, $productId);
|
||||
if ($context === null) {
|
||||
return null;
|
||||
}
|
||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||
? self::ONLY_TANKCLEANING_MESSAGE
|
||||
: null;
|
||||
}
|
||||
|
||||
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||
{
|
||||
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||
}
|
||||
|
||||
public static function isTankCleaningProductRow(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1 || $productId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
o.customer_id AS customer_number,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name,
|
||||
p.category AS product_category,
|
||||
c.name AS category_name,
|
||||
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||
FROM orders o
|
||||
LEFT JOIN products p ON p.id = {$productId}
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||
WHERE o.id = {$orderId}
|
||||
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
));
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
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
|
||||
*/
|
||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||
{
|
||||
$order = (new orders_o())->getOrderById($orderId);
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = (new products_o())->getProductById($productId);
|
||||
if (!$product->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
|
||||
if (!$customer->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$categoryId = (int)$product->category->value();
|
||||
$categoryName = $this->categoryName($categoryId);
|
||||
$searchableProduct = $this->searchableProductText($product, $categoryName);
|
||||
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
||||
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
||||
return $this->violation('restrictAdditionalServices');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
return $this->violation('restrictTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
return $this->violation('onlyTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictSpotFree')
|
||||
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
|
||||
return $this->violation('restrictSpotFree');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
|
||||
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
|
||||
return $this->violation('restrictInteriorCleaning');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}
|
||||
*/
|
||||
private function violation(string $rule): array
|
||||
{
|
||||
return [
|
||||
'rule' => $rule,
|
||||
'message' => self::BLOCK_MESSAGE,
|
||||
];
|
||||
}
|
||||
|
||||
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($relatedItemId !== null && $relatedItemId > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->countStandaloneOrderItems($orderId) > 0;
|
||||
}
|
||||
|
||||
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function containsAny(string $value, array $terms): bool
|
||||
{
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($value, $term)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function categoryName(int $categoryId): string
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return strtolower((string)($row['name'] ?? ''));
|
||||
}
|
||||
|
||||
private function countStandaloneOrderItems(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT COUNT(*) AS item_count
|
||||
FROM order_items
|
||||
WHERE order_id = ' . $orderId . '
|
||||
AND deleted_at IS NULL
|
||||
AND (related_item_id IS NULL OR related_item_id = 0)'
|
||||
);
|
||||
if (!$result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)($row['item_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
||||
AFTER archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
|
||||
@@ -172,7 +172,8 @@ class economic implements economic_i
|
||||
string $email,
|
||||
int $phone,
|
||||
?int $mobile_phone = null,
|
||||
object|array|null $company_information = null
|
||||
object|array|null $company_information = null,
|
||||
?string $ean = null
|
||||
): object
|
||||
{
|
||||
$payload = [
|
||||
@@ -196,10 +197,37 @@ class economic implements economic_i
|
||||
];
|
||||
|
||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||
$normalized_ean = self::normalizeCustomerEan($ean);
|
||||
if ($normalized_ean !== null) {
|
||||
$payload['ean'] = $normalized_ean;
|
||||
}
|
||||
|
||||
return $this->customers->customers->create($payload);
|
||||
}
|
||||
|
||||
public static function normalizeCustomerEan(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', (string)$value);
|
||||
if (!is_string($digits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = trim($digits);
|
||||
if ($digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($digits) > 13) {
|
||||
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
|
||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||
{
|
||||
if ($company_information === null) {
|
||||
|
||||
@@ -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,16 @@ 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;
|
||||
}
|
||||
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||
$fixed_price = (float)$discount_row['fixed_price'];
|
||||
$order_discount_total += (($base_price - $fixed_price) * $quantity);
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage <= 0) {
|
||||
continue;
|
||||
@@ -1519,7 +1529,13 @@ 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;
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage > 0) {
|
||||
$line_price *= (1 - ($discount_percentage / 100));
|
||||
@@ -1529,15 +1545,22 @@ 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);
|
||||
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
|
||||
$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
|
||||
)) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $direct;
|
||||
}
|
||||
|
||||
@@ -1545,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,11 +60,13 @@ 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,
|
||||
object_id VARCHAR(64) NOT NULL,
|
||||
discount INT NOT NULL,
|
||||
fixed_price INT NULL DEFAULT NULL,
|
||||
effective_from DATETIME NOT NULL,
|
||||
effective_to DATETIME NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'live',
|
||||
@@ -74,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",
|
||||
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
|
||||
$db->query(
|
||||
"ALTER TABLE customer_discount_override_versions
|
||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
||||
AFTER discount"
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
|
||||
string $source = 'live.discount_override',
|
||||
float $confidence = 1.0,
|
||||
bool $inferred = false,
|
||||
array $metadata = []
|
||||
array $metadata = [],
|
||||
?int $fixed_price = null,
|
||||
?int $department_id = null
|
||||
): array {
|
||||
$identity = [
|
||||
'user_id' => $user_id,
|
||||
@@ -143,8 +145,11 @@ 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) {
|
||||
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
|
||||
return $this->closeActiveVersion(
|
||||
'customer_discount_override_versions',
|
||||
$identity,
|
||||
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
|
||||
$identity,
|
||||
[
|
||||
'discount' => (int)$discount,
|
||||
'fixed_price' => $is_category ? null : $fixed_price,
|
||||
],
|
||||
$this->normalizeDatetime($effective_from),
|
||||
$source,
|
||||
@@ -238,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
|
||||
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
|
||||
}
|
||||
|
||||
// Discount overrides current state.
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
|
||||
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
|
||||
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
|
||||
($has_override_fixed_price ? ', po.fixed_price' : '') .
|
||||
($has_override_created_at ? ', po.created_at' : '');
|
||||
$discount_rows = $this->fetchAll(
|
||||
"SELECT $discount_cols
|
||||
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
|
||||
'backfill.current_discount_override',
|
||||
$confidence,
|
||||
true,
|
||||
['table' => 'price_overrides']
|
||||
['table' => 'price_overrides'],
|
||||
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
|
||||
);
|
||||
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
|
||||
}
|
||||
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
|
||||
$bucket[$action]++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,12 +92,17 @@ class error_report_service
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
$runtimeContext['screenshot_attachment'] = [
|
||||
'status' => $storedScreenshot['status'],
|
||||
'attached' => $storedScreenshot['key'] !== '',
|
||||
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
|
||||
'size_bytes' => (int)$storedScreenshot['size_bytes'],
|
||||
];
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
@@ -295,6 +300,67 @@ class error_report_service
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function storeOptionalScreenshot(mixed $value, array $context): array
|
||||
{
|
||||
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
|
||||
return $this->emptyScreenshotAttachment('invalid');
|
||||
}
|
||||
|
||||
$dataUri = trim((string)($value ?? ''));
|
||||
if ($dataUri === '') {
|
||||
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
|
||||
}
|
||||
|
||||
try {
|
||||
$screenshot = self::decodeScreenshotDataUri($dataUri);
|
||||
} catch (RuntimeException $exception) {
|
||||
$message = strtolower($exception->getMessage());
|
||||
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
} catch (Throwable) {
|
||||
return $this->emptyScreenshotAttachment('storage_failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => (string)($storedScreenshot['key'] ?? ''),
|
||||
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
|
||||
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
|
||||
'status' => 'stored',
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyScreenshotAttachment(string $status): array
|
||||
{
|
||||
return [
|
||||
'key' => '',
|
||||
'mime_type' => '',
|
||||
'size_bytes' => 0,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
private function contextScreenshotStatus(array $context): ?string
|
||||
{
|
||||
$attachment = $context['screenshot_attachment'] ?? null;
|
||||
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
|
||||
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
|
||||
|
||||
return $this->normalizeEmptyScreenshotStatus($status);
|
||||
}
|
||||
|
||||
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
@@ -432,6 +498,10 @@ class error_report_service
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
|
||||
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
|
||||
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
|
||||
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
@@ -449,10 +519,10 @@ class error_report_service
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'screenshot' => $hasScreenshot ? [
|
||||
'mime_type' => $screenshotMimeType,
|
||||
'size_bytes' => $screenshotSizeBytes,
|
||||
] : null,
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
@@ -467,8 +537,11 @@ class error_report_service
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
if ($hasScreenshot) {
|
||||
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
||||
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
||||
}
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
|
||||
@@ -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 = [];
|
||||
@@ -30,6 +31,8 @@ class invoice_period_flag_service
|
||||
public function __construct()
|
||||
{
|
||||
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
|
||||
@@ -688,6 +691,7 @@ class invoice_period_flag_service
|
||||
o.po AS order_po,
|
||||
o.notes AS order_notes,
|
||||
o.department_id,
|
||||
d.custom_pricing_only AS department_custom_pricing_only,
|
||||
o.reg_1,
|
||||
o.invoice_collection_id,
|
||||
o.wash_id,
|
||||
@@ -710,8 +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,
|
||||
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
|
||||
@@ -720,11 +737,12 @@ class invoice_period_flag_service
|
||||
GROUP BY customer_number
|
||||
) u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN departments d ON d.id = o.department_id
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
|
||||
FROM price_overrides po
|
||||
INNER JOIN users discount_user ON discount_user.id = po.user_id
|
||||
WHERE po.is_category = 0
|
||||
@@ -741,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";
|
||||
@@ -896,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']],
|
||||
];
|
||||
@@ -1921,7 +1965,19 @@ class invoice_period_flag_service
|
||||
|
||||
private function calculateExpectedPrice(array $row): int
|
||||
{
|
||||
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
if ($customMissingPrice) {
|
||||
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
}
|
||||
|
||||
$fixedPrice = $this->rowProductFixedPrice($row);
|
||||
if ($fixedPrice !== null) {
|
||||
return $fixedPrice;
|
||||
}
|
||||
|
||||
$base = $row['department_price'] !== null
|
||||
? (int)$row['department_price']
|
||||
: (int)($row['product_base_price'] ?? 0);
|
||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||
return (int)round($base * (1 - ($discount / 100)));
|
||||
}
|
||||
@@ -1929,13 +1985,18 @@ class invoice_period_flag_service
|
||||
private function priceBreakdown(array $row, int $expected): array
|
||||
{
|
||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||
$discount = $this->discountBreakdown($row);
|
||||
if ($customMissingPrice) {
|
||||
$discount['applied_discount_percentage'] = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'product_price' => (int)($row['product_base_price'] ?? 0),
|
||||
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_base_price' => $base,
|
||||
'product_fixed_price' => $this->rowProductFixedPrice($row),
|
||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||
'category_discount_percentage' => $discount['category_discount_percentage'],
|
||||
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
|
||||
@@ -1944,21 +2005,36 @@ class invoice_period_flag_service
|
||||
];
|
||||
}
|
||||
|
||||
private function isCustomMissingDepartmentPrice(array $row): bool
|
||||
{
|
||||
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
private function discountBreakdown(array $row): array
|
||||
{
|
||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
|
||||
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
|
||||
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
|
||||
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
|
||||
? 0
|
||||
: max($productDiscount, $categoryDiscount, $economicDiscount);
|
||||
|
||||
return [
|
||||
'product_discount_percentage' => $productDiscount,
|
||||
'category_discount_percentage' => $categoryDiscount,
|
||||
'economic_customer_discount_percentage' => $economicDiscount,
|
||||
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
|
||||
'applied_discount_percentage' => $appliedDiscount,
|
||||
];
|
||||
}
|
||||
|
||||
private function rowProductFixedPrice(array $row): ?int
|
||||
{
|
||||
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
|
||||
? (int)$row['product_fixed_price']
|
||||
: null;
|
||||
}
|
||||
|
||||
private function economicCustomerDiscountPercentage(array $row): int
|
||||
{
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
@@ -2022,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'] ?? '')
|
||||
@@ -2036,8 +2117,7 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for customer product price overrides.
|
||||
*/
|
||||
class price_overrides_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureColumns(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'price_overrides')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
|
||||
$db->query(
|
||||
"ALTER TABLE price_overrides
|
||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
||||
AFTER percentage"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$column = self::escapeIdentifier($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -499,6 +499,7 @@ class system_search_document_index
|
||||
*/
|
||||
private function buildCustomerDiscountDocuments(): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
|
||||
$selectFields = [
|
||||
'po.id AS entity_id',
|
||||
@@ -506,6 +507,7 @@ class system_search_document_index
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
@@ -554,6 +556,7 @@ class system_search_document_index
|
||||
$row['search_text'] ?? null,
|
||||
$row['product_or_category_id'] ?? null,
|
||||
$row['percentage'] ?? null,
|
||||
$row['fixed_price'] ?? null,
|
||||
$row['user_id'] ?? null,
|
||||
]),
|
||||
$this->toIntOrNull($row['customer_number'] ?? null),
|
||||
@@ -564,6 +567,7 @@ class system_search_document_index
|
||||
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
|
||||
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
'is_category' => $row['is_category'] ?? null,
|
||||
|
||||
@@ -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,
|
||||
@@ -1086,6 +1126,7 @@ class system_search_service
|
||||
|
||||
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$customerFilter = '';
|
||||
if (!empty($forcedCustomerNumbers)) {
|
||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
|
||||
@@ -1100,11 +1141,12 @@ class system_search_service
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
@@ -1166,6 +1208,7 @@ class system_search_service
|
||||
'search_text',
|
||||
'product_or_category_id',
|
||||
'percentage',
|
||||
'fixed_price',
|
||||
'user_id',
|
||||
], $terms) + $entityBoost,
|
||||
'payload' => $this->augmentPayloadWithTemporal([
|
||||
@@ -1173,6 +1216,7 @@ class system_search_service
|
||||
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
|
||||
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
], $row),
|
||||
@@ -2643,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)) {
|
||||
@@ -2702,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;
|
||||
}
|
||||
@@ -2725,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 === '') {
|
||||
@@ -2981,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'];
|
||||
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
|
||||
{
|
||||
global $db;
|
||||
(new xlvask_usage_logs_o())->structure();
|
||||
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
|
||||
$where = [
|
||||
'FinishStatus = 1',
|
||||
'(ignored_at IS NULL OR ignored_at = "")',
|
||||
];
|
||||
|
||||
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
} else {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
}
|
||||
|
||||
if ($dateTo !== null && strtotime($dateTo) !== false) {
|
||||
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
}
|
||||
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
@@ -14,6 +14,8 @@ class economic_customer_mo
|
||||
public null|string $message;
|
||||
public null|string $corporateIdentificationNumber;
|
||||
public null|string $email;
|
||||
public null|string $ean;
|
||||
public null|string $publicEntryNumber;
|
||||
public null|string $mobilePhone;
|
||||
public null|string $currency;
|
||||
public null|string $country;
|
||||
@@ -46,6 +48,8 @@ class economic_customer_mo
|
||||
$this->zip = ($customer->zip ?? null);
|
||||
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
|
||||
$this->email = ($customer->email ?? null);
|
||||
$this->ean = ($customer->ean ?? null);
|
||||
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
|
||||
$this->mobilePhone = ($customer->mobilePhone ?? null);
|
||||
$this->currency = ($customer->currency ?? null);
|
||||
$this->country = ($customer->country ?? null);
|
||||
@@ -100,6 +104,8 @@ class economic_customer_mo
|
||||
'zip' => $this->zip,
|
||||
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
|
||||
'email' => $this->email,
|
||||
'ean' => $this->ean,
|
||||
'publicEntryNumber' => $this->publicEntryNumber,
|
||||
'mobilePhone' => $this->mobilePhone,
|
||||
'currency' => $this->currency,
|
||||
'country' => $this->country,
|
||||
|
||||
+19
-10
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
|
||||
$customer_address = $customer->getAddress() ?? 'Ukendt';
|
||||
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
|
||||
$customer_city = $customer->getCity() ?? 'Ukendt';
|
||||
$recipient = [
|
||||
'name' => $customer_name,
|
||||
'address' => $customer_address,
|
||||
'zip' => $customer_zip,
|
||||
'city' => $customer_city,
|
||||
'vatZone' => [
|
||||
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
|
||||
],
|
||||
];
|
||||
$customer_ean = $customer->getEan();
|
||||
if ($customer_ean !== null) {
|
||||
$recipient['ean'] = $customer_ean;
|
||||
}
|
||||
$public_entry_number = $customer->getPublicEntryNumber();
|
||||
if ($public_entry_number !== null) {
|
||||
$recipient['publicEntryNumber'] = $public_entry_number;
|
||||
}
|
||||
|
||||
// Send the request
|
||||
$response = $this->send_request(
|
||||
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
|
||||
'currency' => $customer->getCurrency() ?? 'DKK',
|
||||
|
||||
// Set the recipient details
|
||||
'recipient' => [
|
||||
'name' => $customer_name,
|
||||
'address' => $customer_address,
|
||||
'zip' => $customer_zip,
|
||||
'city' => $customer_city,
|
||||
'vatZone' => [
|
||||
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
|
||||
],
|
||||
],
|
||||
'recipient' => $recipient,
|
||||
])
|
||||
);
|
||||
// Return the response as an object
|
||||
@@ -194,4 +203,4 @@ class economic_invoices_drafts_endpoint
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,29 @@ class economic_customer
|
||||
return $this->customer_data_object->email;
|
||||
}
|
||||
|
||||
public function getEan(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
return $this->nullableStringField('ean');
|
||||
}
|
||||
|
||||
public function getPublicEntryNumber(): ?string
|
||||
{
|
||||
self::requireSelected();
|
||||
return $this->nullableStringField('publicEntryNumber');
|
||||
}
|
||||
|
||||
protected function nullableStringField(string $field): ?string
|
||||
{
|
||||
$value = $this->customer_data_object->{$field} ?? null;
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the customer address
|
||||
* @return string The customer address
|
||||
@@ -227,4 +250,4 @@ class economic_customer
|
||||
return $this->customer_data_object->vatZone->vatZoneNumber;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
@@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper
|
||||
|
||||
private function unsetNullifiableProperties(): void
|
||||
{
|
||||
$nullable_review_metadata = [
|
||||
'ignored_at',
|
||||
'ignored_reason',
|
||||
];
|
||||
// Unset properties that are null or empty strings
|
||||
$properties = [
|
||||
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
|
||||
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
|
||||
if ($this->isEmptyOrDefault($this->{$property})) {
|
||||
$tmp_value = $this->{$property};
|
||||
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) {
|
||||
$this->{$property} = ''; // Set to null if it matches the default string
|
||||
$this->{$property} = (
|
||||
$tmp_value === $this->default_string_nullable
|
||||
&& in_array($property, $nullable_review_metadata, true)
|
||||
) ? null : '';
|
||||
} elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
|
||||
if ($tmp_value === $this->default_int_nullable) {
|
||||
$this->{$property} = null; // Set to null if it matches the default int nullable
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ class departments_o extends db
|
||||
public object_property $dimension; // The dimension of the department
|
||||
public object_property $visible; // The visibility of the department
|
||||
public object_property $archived; // Whether the department is archived
|
||||
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
|
||||
public object_property $branding; // The branding of the department
|
||||
public object_property $longitude; // The longitude of the department (Can be null)
|
||||
public object_property $latitude; // The latitude of the department (Can be null)
|
||||
@@ -107,6 +108,7 @@ class departments_o extends db
|
||||
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
||||
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
|
||||
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
|
||||
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
|
||||
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
|
||||
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
|
||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||
@@ -185,6 +187,12 @@ class departments_o extends db
|
||||
return $department;
|
||||
}
|
||||
|
||||
public function isCustomPricingOnly(int $department_id): bool
|
||||
{
|
||||
$department = $this->getDepartmentById($department_id);
|
||||
return (bool)(int)($department['custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the price of a product in a department
|
||||
* @param int $department_id
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\customer_order_product_policy;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
@@ -93,6 +94,7 @@ class order_items_o extends db
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Avoid SQL injection
|
||||
$reference = $db->escape_string($reference);
|
||||
$notes = $db->escape_string($notes);
|
||||
@@ -167,18 +169,20 @@ class order_items_o extends db
|
||||
try {
|
||||
// Get the order
|
||||
$order = (new orders_o())->getOrderById($order_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Get the product price
|
||||
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
||||
$product = (new products_o())->getProductById($product_id);
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
||||
$price = $priceResolution['price'];
|
||||
|
||||
// Check if the user has a discount on the product, or category
|
||||
$customer = (new orders_o())->getOrderCustomer($order_id);
|
||||
$discount = $customer->getCustomPrice($product_id, false);
|
||||
if ($discount) {
|
||||
$price = $price - ($price * $discount / 100);
|
||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
$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
|
||||
if ($forcePrice) {
|
||||
if ($forcePrice !== null) {
|
||||
$price = (int)$forcePrice;
|
||||
}
|
||||
|
||||
@@ -354,4 +358,4 @@ class order_items_o extends db
|
||||
{
|
||||
return (new products_o())->select((int)$this->product_id->value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1428,15 +1431,16 @@ class orders_o extends db
|
||||
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
|
||||
$order_item->reference->set('');
|
||||
// Get the product price based on the department
|
||||
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||
$product_price = (int)$priceResolution['price']; // Get the department price for the product
|
||||
// Get the customers custom price discount percentage
|
||||
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
|
||||
if (!$user->exists()) {
|
||||
throw new Exception('No user found matching the customer number in the usage log');
|
||||
}
|
||||
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
|
||||
// Apply the discount percentage to the product price
|
||||
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
|
||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value());
|
||||
}
|
||||
$order_item->notes->set(null); // Set notes for the simulated order item
|
||||
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
||||
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
|
||||
@@ -1503,11 +1507,12 @@ class orders_o extends db
|
||||
if (!$current_user->exists()) {
|
||||
throw new Exception('No current user found');
|
||||
}
|
||||
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
|
||||
// Apply the discount percentage to the product price
|
||||
// Apply the discount percentage to the product price
|
||||
return (int)round($price * (1 - ($discount_percentage / 100)));
|
||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
||||
$price = (int)$priceResolution['price']; // Get the department price for the product
|
||||
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
||||
return $price;
|
||||
}
|
||||
return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1589,13 +1594,16 @@ class orders_o extends db
|
||||
$product_id = (int)$item['product_id'];
|
||||
if (!isset($department_price_cache[$product_id])) {
|
||||
$product = (new products_o())->select($product_id);
|
||||
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
|
||||
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
|
||||
}
|
||||
if ($tmp_user === null) {
|
||||
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||
}
|
||||
$discount = $tmp_user->getCustomPrice($product_id, false);
|
||||
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
|
||||
$unitPrice = (int)$department_price_cache[$product_id]['price'];
|
||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
|
||||
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id);
|
||||
}
|
||||
$post_discount = $unitPrice * $quantity;
|
||||
$total += $post_discount;
|
||||
}
|
||||
|
||||
@@ -2000,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2036,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,11 @@ class products_o extends db
|
||||
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
|
||||
public const PRICE_SOURCE_DEPARTMENT = 'department';
|
||||
public const PRICE_SOURCE_DEFAULT = 'default';
|
||||
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
|
||||
public const PRICE_SOURCE_KEY = '_department_price_source';
|
||||
|
||||
/**
|
||||
* The name of the product
|
||||
@@ -255,24 +260,41 @@ class products_o extends db
|
||||
* @param int $department_id
|
||||
* @return array
|
||||
*/
|
||||
public function applyDepartmentPricing(array $products, int $department_id): array
|
||||
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
|
||||
{
|
||||
global $db;
|
||||
$department_id = $db->escape_string($department_id);
|
||||
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
|
||||
$result = $db->query($sql);
|
||||
$prices = $db->fetch_all($result);
|
||||
$priceLookup = [];
|
||||
foreach ($prices as $price) {
|
||||
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
|
||||
}
|
||||
|
||||
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
|
||||
foreach ( $products as $key => $product ) {
|
||||
foreach ( $prices as $price ) {
|
||||
if ((int)$product['id'] === (int)$price['product_id']) {
|
||||
$products[$key]['price'] = $price['price'];
|
||||
}
|
||||
$productId = (int)($product['id'] ?? 0);
|
||||
$source = self::PRICE_SOURCE_DEFAULT;
|
||||
if (array_key_exists($productId, $priceLookup)) {
|
||||
$products[$key]['price'] = $priceLookup[$productId];
|
||||
$source = self::PRICE_SOURCE_DEPARTMENT;
|
||||
} elseif ($customPricingOnly) {
|
||||
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
|
||||
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
|
||||
}
|
||||
|
||||
if ($includePriceSource) {
|
||||
$products[$key][self::PRICE_SOURCE_KEY] = $source;
|
||||
}
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
public function getDepartmentPrice(int $department_id): int
|
||||
/**
|
||||
* @return array{price:int,source:string}
|
||||
*/
|
||||
public function getDepartmentPriceResolution(int $department_id): array
|
||||
{
|
||||
global $db;
|
||||
$department_id = $db->escape_string($department_id);
|
||||
@@ -281,34 +303,62 @@ class products_o extends db
|
||||
$prices = $db->fetch_all($result);
|
||||
// Check if the product has a department price
|
||||
if (count($prices) > 0) {
|
||||
return $prices[0]['price'];
|
||||
return [
|
||||
'price' => (int)$prices[0]['price'],
|
||||
'source' => self::PRICE_SOURCE_DEPARTMENT,
|
||||
];
|
||||
}
|
||||
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
|
||||
return [
|
||||
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
|
||||
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
|
||||
];
|
||||
}
|
||||
// Return the default price
|
||||
return $this->price->value();
|
||||
return [
|
||||
'price' => (int)$this->price->value(),
|
||||
'source' => self::PRICE_SOURCE_DEFAULT,
|
||||
];
|
||||
}
|
||||
|
||||
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
||||
public function getDepartmentPrice(int $department_id): int
|
||||
{
|
||||
return $this->getDepartmentPriceResolution($department_id)['price'];
|
||||
}
|
||||
|
||||
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
|
||||
if (!isset($product['id']) || !isset($product['price'])) {
|
||||
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
|
||||
}
|
||||
// Get the customer's discount percentage
|
||||
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
|
||||
// Apply the discount to the product price
|
||||
if ($discount_percentage > 0) {
|
||||
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
|
||||
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
|
||||
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price'], true, $departmentId);
|
||||
}
|
||||
unset($product[self::PRICE_SOURCE_KEY]);
|
||||
return $product;
|
||||
}
|
||||
|
||||
public static function stripDepartmentPriceSources(array $products): array
|
||||
{
|
||||
return array_map(static function (array $product): array {
|
||||
unset($product[self::PRICE_SOURCE_KEY]);
|
||||
return $product;
|
||||
}, $products);
|
||||
}
|
||||
|
||||
public static function priceResolutionIsCustomMissing(array $resolution): bool
|
||||
{
|
||||
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
|
||||
}
|
||||
|
||||
public function getSubscriptionMonthlyPrice(): int
|
||||
{
|
||||
// Subscription price (for 2 washes per month) is 1.2 times the normal price
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\price_overrides_schema_bootstrap;
|
||||
use classes\system_search_cache;
|
||||
use traits\db_object_t;
|
||||
|
||||
class user_price_overrides_o extends db
|
||||
@@ -14,10 +16,12 @@ class user_price_overrides_o extends db
|
||||
public object_property $is_category;
|
||||
public object_property $product_or_category_id;
|
||||
public object_property $percentage;
|
||||
public object_property $fixed_price;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
$this->setTable('price_overrides');
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
@@ -30,6 +34,7 @@ class user_price_overrides_o extends db
|
||||
$this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true);
|
||||
$this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true);
|
||||
$this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true);
|
||||
$this->fixed_price = new object_property($this->table, $this->id, 'fixed_price', 'int', false, null);
|
||||
}
|
||||
|
||||
public function setUser($user_id): user_price_overrides_o
|
||||
@@ -43,39 +48,41 @@ class user_price_overrides_o extends db
|
||||
* @param bool $is_category
|
||||
* @param int|string $product_or_category_id
|
||||
* @param int $percentage
|
||||
* @param int|null $fixed_price
|
||||
* @return $this
|
||||
*/
|
||||
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage): user_price_overrides_o
|
||||
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage, ?int $fixed_price = null): user_price_overrides_o
|
||||
{
|
||||
global $db;
|
||||
// If the user is not set, return the object
|
||||
if (!isset($this->user_id)) {
|
||||
return $this;
|
||||
}
|
||||
if ($is_category) {
|
||||
$fixed_price = null;
|
||||
}
|
||||
// Check if the record already exists
|
||||
$this->removePriceIfExist($is_category, $product_or_category_id);
|
||||
// If the percentage is 0, return the object
|
||||
if ($percentage === 0) {
|
||||
// If neither a discount nor a fixed product price is set, remove the record.
|
||||
if ($percentage === 0 && $fixed_price === null) {
|
||||
return $this;
|
||||
}
|
||||
// Create a new record in the database
|
||||
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage) VALUES ($this->user_id, " . (int)$is_category . ", '$product_or_category_id', $percentage)";
|
||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
||||
$fixed_price_sql = $fixed_price === null ? 'NULL' : (string)max(0, (int)$fixed_price);
|
||||
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage, fixed_price) VALUES (" . (int)$this->user_id . ", " . (int)$is_category . ", '$product_or_category_id', " . (int)$percentage . ", $fixed_price_sql)";
|
||||
$db->query($sql);
|
||||
$this->markSearchDirty();
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
|
||||
{
|
||||
global $db;
|
||||
// Get the price override from the database
|
||||
$sql = "SELECT * FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||
$result = $db->query($sql);
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
// Remove the record from the database
|
||||
$sql = "DELETE FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||
$db->query($sql);
|
||||
}
|
||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
||||
$sql = "DELETE FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||
$db->query($sql);
|
||||
$this->markSearchDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +139,49 @@ class user_price_overrides_o extends db
|
||||
return $percentage;
|
||||
}
|
||||
|
||||
public function getFixedPrice(bool $is_category, int|string $product_or_category_id): ?int
|
||||
{
|
||||
if ($is_category || !isset($this->user_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->getDirectPriceRow(false, (int)$product_or_category_id);
|
||||
if ($row === null || $row['fixed_price'] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)$row['fixed_price'];
|
||||
}
|
||||
|
||||
public function getDirectPriceRow(bool $is_category, int|string $product_or_category_id): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!isset($this->user_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
||||
$sql = "SELECT * FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id' LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['user_id'] = (int)$row['user_id'];
|
||||
$row['is_category'] = (bool)$row['is_category'];
|
||||
$row['product_or_category_id'] = $is_category
|
||||
? (string)$row['product_or_category_id']
|
||||
: (int)$row['product_or_category_id'];
|
||||
$row['percentage'] = (int)$row['percentage'];
|
||||
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null
|
||||
? (int)$row['fixed_price']
|
||||
: null;
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the price overrides for the user
|
||||
* @return array
|
||||
@@ -153,6 +203,7 @@ class user_price_overrides_o extends db
|
||||
$row['is_category'] = (bool)$row['is_category'];
|
||||
$row['product_or_category_id'] = (int)$row['product_or_category_id'];
|
||||
$row['percentage'] = (int)$row['percentage'];
|
||||
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null;
|
||||
$row['created_at'] = (string)$row['created_at'];
|
||||
$row['updated_at'] = (string)$row['updated_at'];
|
||||
// Add the row to the list
|
||||
@@ -167,10 +218,19 @@ class user_price_overrides_o extends db
|
||||
'is_category' => true,
|
||||
'product_or_category_id' => "global",
|
||||
'percentage' => (int)$economic_user_global_discount,
|
||||
'fixed_price' => null,
|
||||
'created_at' => "2021-01-01 00:00:00",
|
||||
'updated_at' => "2021-01-01 00:00:00"
|
||||
];
|
||||
}
|
||||
return $prices;
|
||||
}
|
||||
}
|
||||
|
||||
private function markSearchDirty(): void
|
||||
{
|
||||
try {
|
||||
system_search_cache::markDirtyTable($this->table);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +993,71 @@ class users_o extends db
|
||||
return $discount_percentage === null ? 0 : (int)$discount_percentage;
|
||||
}
|
||||
|
||||
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 $department_id = null): int
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$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, $department_id)
|
||||
: (int)$this->getCustomPrice($product_id, false, $department_id);
|
||||
if ($discount_percentage <= 0) {
|
||||
return $base_price;
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@@ -1077,9 +1154,65 @@ class users_o extends db
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $users
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function markLimitedBackofficeManagedUsers(array $users): array
|
||||
{
|
||||
$userIds = [];
|
||||
foreach ($users as $user) {
|
||||
$userId = (int)($user['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
$userIds[$userId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($userIds === []) {
|
||||
return $users;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$rows = $db->fetch_all($db->query(
|
||||
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` IN (' .
|
||||
implode(',', array_map('intval', array_keys($userIds))) .
|
||||
')'
|
||||
));
|
||||
|
||||
$managedUserIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$managedUserIds[(int)$row['user_id']] = true;
|
||||
}
|
||||
|
||||
foreach ($users as $key => $user) {
|
||||
$users[$key]['limited_backoffice_managed'] = isset($managedUserIds[(int)($user['id'] ?? 0)]);
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function isLimitedBackofficeManagedUser(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$userId . ' LIMIT 1'
|
||||
);
|
||||
|
||||
return $result !== false && $result->num_rows > 0;
|
||||
}
|
||||
|
||||
public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
|
||||
{
|
||||
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) {
|
||||
if ((bool)($value['limited_backoffice_managed'] ?? false) || (int)($value['customer_number'] ?? -1) === 0) {
|
||||
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $value['display_name'] ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
|
||||
}
|
||||
return $listObjectsWithPaginationIfSet;
|
||||
@@ -1183,15 +1316,16 @@ class users_o extends db
|
||||
* @param int $object_id The ID of the object
|
||||
* @param int $discount_percentage The discount percentage
|
||||
* @param bool $is_category If the object is a category
|
||||
* @param int|null $fixed_price The fixed product price, when set
|
||||
* @return void
|
||||
*/
|
||||
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false): void
|
||||
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false, ?int $fixed_price = null): void
|
||||
{
|
||||
$this->id = $user_id;
|
||||
// Get the user object properties
|
||||
$this->getObjectProperties();
|
||||
// Set the custom price (key = 'custom_price')
|
||||
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage);
|
||||
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage, $fixed_price);
|
||||
}
|
||||
|
||||
public function syncAllUsersEconomicCustomerDetails(): void
|
||||
|
||||
@@ -82,7 +82,7 @@ class xlvask_usage_logs_o extends db
|
||||
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
|
||||
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
|
||||
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false);
|
||||
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
|
||||
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
|
||||
}
|
||||
@@ -195,18 +195,20 @@ class xlvask_usage_logs_o extends db
|
||||
|
||||
/**
|
||||
* Import the usage logs from XL Vask
|
||||
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
|
||||
* @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'.
|
||||
* @param string|null $dateTo Optional inclusive import end date.
|
||||
* @throws Exception If the objects were not successfully added.
|
||||
* @returns void
|
||||
*/
|
||||
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void
|
||||
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
|
||||
{
|
||||
if (!empty($this->id)) {
|
||||
throw new Exception('To prevent issues, having a selected object is not allowed.');
|
||||
}
|
||||
$usage_logs = $this->getUsageLogsFromXLVask(
|
||||
date('Y-m-d\TH:i:s.000', strtotime($dateTimeModifier)) // Example: '2025-05-01T00:00:00.000'
|
||||
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
|
||||
);
|
||||
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
|
||||
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
|
||||
$known_usage_logIds = array_map(function ($log) {
|
||||
return $log['WashId'];
|
||||
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
|
||||
unset($new_usage_logs);
|
||||
}
|
||||
|
||||
private static function formatImportDateFrom(?string $dateFrom): string
|
||||
{
|
||||
$dateFrom = trim((string)($dateFrom ?? ''));
|
||||
$timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom);
|
||||
|
||||
if ($timestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateFrom');
|
||||
}
|
||||
|
||||
return date('Y-m-d\TH:i:s.000', $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param xlvask_usage_log[] $usageLogs
|
||||
* @return xlvask_usage_log[]
|
||||
* @throws Exception
|
||||
*/
|
||||
private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array
|
||||
{
|
||||
$dateTo = trim((string)($dateTo ?? ''));
|
||||
if ($dateTo === '') {
|
||||
return $usageLogs;
|
||||
}
|
||||
|
||||
$dateToTimestamp = strtotime($dateTo);
|
||||
if ($dateToTimestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateTo');
|
||||
}
|
||||
|
||||
$inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp));
|
||||
if ($inclusiveEndTimestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateTo');
|
||||
}
|
||||
|
||||
return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) {
|
||||
$startTimestamp = strtotime((string)$log->StartTime);
|
||||
return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function retrieves the usage logs from XL Vask
|
||||
* @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
|
||||
|
||||
+1011
-81
File diff suppressed because it is too large
Load Diff
@@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute
|
||||
$product_cache[$product_id] = (new products_o())->select($product_id);
|
||||
}
|
||||
if (!isset($department_price_cache[$department_id][$product_id])) {
|
||||
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
|
||||
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
|
||||
}
|
||||
if (!array_key_exists($product_id, $discount_cache)) {
|
||||
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
|
||||
$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, $department_id);
|
||||
}
|
||||
$discount_cache[$product_id] = $unit_price;
|
||||
}
|
||||
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
|
||||
$post_discount = (int)$discount_cache[$product_id] * $quantity;
|
||||
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
|
||||
}
|
||||
|
||||
|
||||
@@ -427,6 +427,7 @@ class authRoute
|
||||
$contactEmail = self::getParameter('contactEmail');
|
||||
$contactPhone = (int)self::getParameter('contactPhone');
|
||||
$contactName = self::getParameter('contactName');
|
||||
$ean = null;
|
||||
/**
|
||||
* Validate
|
||||
*/
|
||||
@@ -454,6 +455,13 @@ class authRoute
|
||||
self::requireMinValue($contactPhone, 10000000);
|
||||
self::requireMaxValue($contactPhone, 9999999999);
|
||||
}
|
||||
if (self::isParametersSet(['ean'])) {
|
||||
try {
|
||||
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the contact phone is empty, default to company phone
|
||||
@@ -545,6 +553,7 @@ class authRoute
|
||||
(int)$companyPhone,
|
||||
(int)$contactPhone,
|
||||
$companyInformation,
|
||||
$ean,
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use DateTimeZone;
|
||||
use Exception;
|
||||
use objects\department_daily_report_complaints_o;
|
||||
use objects\department_daily_reports_o;
|
||||
use objects\department_variables_o;
|
||||
use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/departments/daily-reports', function () {
|
||||
@@ -850,7 +853,8 @@ class departmentDailyReportsRoute
|
||||
'overview' => $this->buildDailyReportOverview(
|
||||
[$department_id],
|
||||
(string)self::getParameter('date'),
|
||||
$date_to
|
||||
$date_to,
|
||||
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
|
||||
),
|
||||
]);
|
||||
},
|
||||
@@ -895,7 +899,8 @@ class departmentDailyReportsRoute
|
||||
$this->buildDailyReportOverview(
|
||||
$department_ids,
|
||||
(string)self::getParameter('date'),
|
||||
$date_to
|
||||
$date_to,
|
||||
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
|
||||
)
|
||||
);
|
||||
},
|
||||
@@ -906,6 +911,73 @@ class departmentDailyReportsRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->put('/departments/daily-reports/product-targets', function () {
|
||||
global $response;
|
||||
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
self::requireParameters([
|
||||
'department_id',
|
||||
'product_id',
|
||||
'target_percentage',
|
||||
]);
|
||||
|
||||
$department_id = (int)self::getParameter('department_id');
|
||||
if ($department_id <= 0) {
|
||||
$response->error('Parameter department_id must be a positive integer', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$department = (new departments_o())->select($department_id);
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
self::requireDepartmentAccess($department_id);
|
||||
|
||||
$product_id = (int)self::getParameter('product_id');
|
||||
if (!$this->isDailyReportProductId($product_id)) {
|
||||
$response->error('Invalid daily report product_id', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
|
||||
if (!$parsed_target['valid']) {
|
||||
$response->error($parsed_target['message'], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$target_percentage = $parsed_target['value'];
|
||||
$department_variables = (new department_variables_o())->selectDepartment($department_id);
|
||||
$target_key = $this->dailyReportProductTargetVariableKey($product_id);
|
||||
|
||||
if ($target_percentage === null) {
|
||||
$this->clearDailyReportProductTarget($department_variables, $target_key);
|
||||
} else {
|
||||
$department_variables->set($target_key, number_format($target_percentage, 1, '.', ''));
|
||||
}
|
||||
|
||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'Successfully updated department daily report product target');
|
||||
|
||||
$response->success([
|
||||
'department_id' => $department_id,
|
||||
'product_id' => $product_id,
|
||||
'target_percentage' => $target_percentage,
|
||||
]);
|
||||
},
|
||||
[
|
||||
self::SET_PRODUCT_TARGET_PERMISSION => 'Set department daily report product target percentages',
|
||||
'department_access_:department_id' => 'Access the department'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/product-count', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -1369,7 +1441,7 @@ class departmentDailyReportsRoute
|
||||
* }
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to): array
|
||||
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to, bool $include_product_targets = false): array
|
||||
{
|
||||
$repository = $this->dailyReportRepository();
|
||||
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
|
||||
@@ -1389,6 +1461,11 @@ class departmentDailyReportsRoute
|
||||
|
||||
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to);
|
||||
|
||||
$product_target_lookup = [];
|
||||
if ($include_product_targets && count($department_ids) === 1) {
|
||||
$product_target_lookup = $this->getDailyReportProductTargetsForDepartment((int)$department_ids[0], $product_definitions);
|
||||
}
|
||||
|
||||
return $this->assembleDailyReportOverview(
|
||||
$department_ids,
|
||||
$date,
|
||||
@@ -1399,7 +1476,8 @@ class departmentDailyReportsRoute
|
||||
$product_summary_lookup,
|
||||
$complaints_metric,
|
||||
$night_wash_metric,
|
||||
$overtime_metric
|
||||
$overtime_metric,
|
||||
$product_target_lookup
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1412,6 +1490,7 @@ class departmentDailyReportsRoute
|
||||
* @param array<string,mixed> $complaints_metric
|
||||
* @param array<string,mixed> $night_wash_metric
|
||||
* @param array<string,mixed> $overtime_metric
|
||||
* @param array<int,float> $product_target_lookup
|
||||
* @return array{
|
||||
* department_ids:array<int>,
|
||||
* date:string,
|
||||
@@ -1430,7 +1509,8 @@ class departmentDailyReportsRoute
|
||||
array $product_summary_lookup,
|
||||
array $complaints_metric,
|
||||
array $night_wash_metric,
|
||||
array $overtime_metric
|
||||
array $overtime_metric,
|
||||
array $product_target_lookup = []
|
||||
): array {
|
||||
$products = [];
|
||||
foreach ($product_definitions as $definition) {
|
||||
@@ -1448,6 +1528,12 @@ class departmentDailyReportsRoute
|
||||
'state' => 'ready',
|
||||
'value' => (int)($product_summary['quantity'] ?? 0),
|
||||
'out_of' => (int)($product_summary['out_of'] ?? 0),
|
||||
'target_percentage' => array_key_exists($product_id, $product_target_lookup)
|
||||
? (float)$product_target_lookup[$product_id]
|
||||
: null,
|
||||
'target_department_id' => array_key_exists($product_id, $product_target_lookup)
|
||||
? (int)$department_ids[0]
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1506,6 +1592,87 @@ class departmentDailyReportsRoute
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
private function isDailyReportProductId(int $product_id): bool
|
||||
{
|
||||
return in_array(
|
||||
$product_id,
|
||||
array_map(static fn(array $definition): int => (int)$definition['product_id'], $this->getDailyReportProductDefinitions()),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid:bool,value:?float,message:string}
|
||||
*/
|
||||
private function parseDailyReportProductTargetPercentage(mixed $target_percentage): array
|
||||
{
|
||||
if ($target_percentage === null) {
|
||||
return ['valid' => true, 'value' => null, 'message' => ''];
|
||||
}
|
||||
|
||||
if (is_string($target_percentage)) {
|
||||
$target_percentage = trim($target_percentage);
|
||||
if ($target_percentage === '') {
|
||||
return ['valid' => true, 'value' => null, 'message' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_int($target_percentage) && !is_float($target_percentage) && !(is_string($target_percentage) && is_numeric($target_percentage))) {
|
||||
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be numeric, null, or empty'];
|
||||
}
|
||||
|
||||
$target_percentage = round((float)$target_percentage, 1);
|
||||
if ($target_percentage < 0.0 || $target_percentage > 100.0) {
|
||||
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be between 0 and 100'];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'value' => $target_percentage, 'message' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{product_id:int,slug:string,title:string}> $product_definitions
|
||||
* @return array<int,float>
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
|
||||
{
|
||||
$department_variables = (new department_variables_o())->selectDepartment($department_id);
|
||||
$targets = [];
|
||||
|
||||
foreach ($product_definitions as $definition) {
|
||||
$product_id = (int)$definition['product_id'];
|
||||
$stored_target = $department_variables->getVariable($this->dailyReportProductTargetVariableKey($product_id));
|
||||
|
||||
if ($stored_target === null || $stored_target === '' || !is_numeric($stored_target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targets[$product_id] = round((float)$stored_target, 1);
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
protected function clearDailyReportProductTarget(department_variables_o $department_variables, string $target_key): void
|
||||
{
|
||||
$existing_targets = $department_variables->getFieldsWhere([
|
||||
'department_id' => $department_variables->department_id,
|
||||
'variable' => $target_key,
|
||||
], ['id']);
|
||||
|
||||
if (!$existing_targets) {
|
||||
return;
|
||||
}
|
||||
|
||||
department_variables_o::delete_object('department_variables', (int)$existing_targets[0]['id']);
|
||||
$department_variables->objectChanged();
|
||||
}
|
||||
|
||||
private function dailyReportProductTargetVariableKey(int $product_id): string
|
||||
{
|
||||
return 'daily_report_product_target_percentage_' . $product_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array{product_id:int,slug:string,title:string}>
|
||||
*/
|
||||
|
||||
@@ -103,6 +103,7 @@ class departmentsRoute
|
||||
'economic_department_id',
|
||||
'visible',
|
||||
'archived',
|
||||
'custom_pricing_only',
|
||||
'longitude',
|
||||
'latitude',
|
||||
])
|
||||
@@ -123,6 +124,12 @@ class departmentsRoute
|
||||
'latitude' => (float)$department['latitude'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
];
|
||||
if (
|
||||
$user->hasPermission('superuser_fetch_department')
|
||||
|| $user->hasPermission('edit_department')
|
||||
) {
|
||||
$tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
|
||||
}
|
||||
// If the user has the permission to view the slack webhook, add it to the response
|
||||
if ($user->hasPermission('view_slack_webhook')) {
|
||||
$tmp_department['slack_webhook'] = $department['slack_webhook'];
|
||||
@@ -220,6 +227,9 @@ class departmentsRoute
|
||||
if (self::isParametersSet(['archived'])) {
|
||||
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
|
||||
}
|
||||
if (self::isParametersSet(['custom_pricing_only'])) {
|
||||
$department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only')));
|
||||
}
|
||||
$department->objectChanged();
|
||||
// Log the incident
|
||||
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
|
||||
@@ -240,11 +250,17 @@ class departmentsRoute
|
||||
$this->get('/departments/categories', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
self::requirePermission('list_department_categories');
|
||||
// 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) {
|
||||
$isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser;
|
||||
if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) {
|
||||
$this->emitForbidden(['list_department_categories']);
|
||||
}
|
||||
|
||||
$responsibleUserId = $user ? (int)$user->id : 0;
|
||||
// Require the department id
|
||||
self::requireParameters(['id']);
|
||||
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
|
||||
@@ -253,14 +269,14 @@ class departmentsRoute
|
||||
// Validate the department categories object
|
||||
if (!$department->exists()) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
||||
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
|
||||
// Return an error
|
||||
$response->error('Department categories not found', 400);
|
||||
}
|
||||
// Get the department categories
|
||||
$department_categories = new department_categories_o();
|
||||
// Log the incident
|
||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
||||
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
|
||||
// Return the list of department categories
|
||||
$response->success(
|
||||
$department_categories
|
||||
@@ -285,7 +301,7 @@ class departmentsRoute
|
||||
}
|
||||
},
|
||||
[
|
||||
'list_department_categories' => 'List all department categories'
|
||||
'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.'
|
||||
]
|
||||
);
|
||||
|
||||
@@ -559,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);
|
||||
|
||||
@@ -579,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,11 +44,38 @@ 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): array {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->rolePresets();
|
||||
return $service->rolePresets($user);
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
@@ -78,6 +105,26 @@ 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);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->put('/limited-backoffice/employees/{employeeId}', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
@@ -134,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,14 @@ class moduleEconomicCustomerRoute
|
||||
self::requireMaxLength('phone', 255);
|
||||
self::requireMinLength('name', 1);
|
||||
self::requireMaxLength('name', 255);
|
||||
$ean = null;
|
||||
if (self::isParametersSet(['ean'])) {
|
||||
try {
|
||||
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
(new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer');
|
||||
$result = (new economic())->createCustomer(
|
||||
(int)self::getParameter('customer_number'),
|
||||
@@ -67,6 +75,9 @@ class moduleEconomicCustomerRoute
|
||||
(int)self::getParameter('cvr'),
|
||||
(string)self::getParameter('email'),
|
||||
(int)self::getParameter('phone'),
|
||||
null,
|
||||
null,
|
||||
$ean,
|
||||
);
|
||||
$response->success((object)$result);
|
||||
} else {
|
||||
@@ -76,4 +87,4 @@ class moduleEconomicCustomerRoute
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
|
||||
$this->get('/modules/xlvask/tasks/import-usage', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_xlvask_import_usage');
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
|
||||
// Create the xlvask_usage_logs_o object
|
||||
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
|
||||
// Import usage logs
|
||||
$xlvask_usage_logs_o->importUsageLogs();
|
||||
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
|
||||
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
|
||||
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
|
||||
// Response
|
||||
$response->success(
|
||||
'Usage logs imported',
|
||||
|
||||
@@ -40,20 +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
|
||||
/**
|
||||
* Permissions (clean helper)
|
||||
*/
|
||||
$permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
|
||||
$permission_other = self::definePermission('add_bookings');
|
||||
self::allowOwnOrDepartmentAccess(
|
||||
$permission_own,
|
||||
$permission_other,
|
||||
$this->requireOrderBookingCreateAccess(
|
||||
(int)$customer_number->customer_number->value(),
|
||||
(int)$department->id,
|
||||
null,
|
||||
'You do not have permission to create this order booking.'
|
||||
(int)$department->id
|
||||
);
|
||||
$items = self::getTargetItems(
|
||||
true,
|
||||
$customer_number,
|
||||
(int)$department->id
|
||||
); // Array of order_items_o objects
|
||||
/**
|
||||
* Input data
|
||||
*/
|
||||
@@ -96,8 +91,8 @@ class orderBookingRoute
|
||||
$response->success($order_bookings_o->asArray());
|
||||
},
|
||||
[
|
||||
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
|
||||
'add_bookings' => 'Permission to create department order bookings.'
|
||||
'add_bookings' => 'Permission to create order bookings for another customer or department scope.',
|
||||
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'
|
||||
]
|
||||
);
|
||||
|
||||
@@ -266,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 */
|
||||
@@ -293,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
|
||||
*/
|
||||
@@ -659,6 +658,48 @@ class orderBookingRoute
|
||||
return $object;
|
||||
}
|
||||
|
||||
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
|
||||
{
|
||||
$auth = new authentication();
|
||||
|
||||
if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) {
|
||||
$permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
|
||||
if (!self::hasPermission($permissionOwn, $targetCustomerNumber)) {
|
||||
$this->emitForbidden([$permissionOwn]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
$auth->get_user() !== false
|
||||
&& self::hasPermission('user')
|
||||
&& $this->isOwnCustomerContext($targetCustomerNumber)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissionOther = self::definePermission('add_bookings');
|
||||
if (!self::hasPermission($permissionOther)) {
|
||||
$this->emitForbidden([$permissionOther]);
|
||||
}
|
||||
|
||||
self::requireDepartmentAccess((string)$departmentId);
|
||||
}
|
||||
|
||||
private function isOrderBookingCustomerSession(): bool
|
||||
{
|
||||
try {
|
||||
$auth = new authentication();
|
||||
if ($auth->get_subuser() !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $auth->get_user() !== false && self::hasPermission('user');
|
||||
} catch (Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception If the Department is invalid.
|
||||
*/
|
||||
@@ -763,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';
|
||||
@@ -778,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;
|
||||
@@ -612,21 +613,46 @@ class orderInvoicesRoute
|
||||
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
|
||||
);
|
||||
|
||||
$date_from = $db->escape_string($date_range['dateFrom']);
|
||||
$date_to = $db->escape_string($date_range['dateTo']);
|
||||
$sql = "SELECT DISTINCT invoice_collection_id
|
||||
FROM orders
|
||||
WHERE created_at BETWEEN '$date_from' AND '$date_to'
|
||||
AND invoice_collection_id IS NOT NULL
|
||||
AND invoice_collection_id > 0
|
||||
AND deleted_at IS NULL";
|
||||
$query_result = $db->query($sql);
|
||||
$invoice_collection_ids = [];
|
||||
while ($row = $query_result->fetch_assoc()) {
|
||||
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
||||
if ($invoice_collection_id > 0) {
|
||||
if (self::isParametersSet(['invoice_collection_ids'])) {
|
||||
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
|
||||
if (!is_array($invoice_collection_ids_raw)) {
|
||||
$response->error('invoice_collection_ids must be an array', 400);
|
||||
}
|
||||
|
||||
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
|
||||
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
|
||||
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||
}
|
||||
|
||||
$invoice_collection_id = (int)$invoice_collection_id_raw;
|
||||
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
|
||||
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
|
||||
}
|
||||
|
||||
$invoice_collection_ids[] = $invoice_collection_id;
|
||||
}
|
||||
|
||||
$invoice_collection_ids = array_values(array_unique($invoice_collection_ids));
|
||||
if (empty($invoice_collection_ids)) {
|
||||
$response->error('invoice_collection_ids must contain at least one id', 400);
|
||||
}
|
||||
} else {
|
||||
$date_from = $db->escape_string($date_range['dateFrom']);
|
||||
$date_to = $db->escape_string($date_range['dateTo']);
|
||||
$sql = "SELECT DISTINCT invoice_collection_id
|
||||
FROM orders
|
||||
WHERE created_at BETWEEN '$date_from' AND '$date_to'
|
||||
AND invoice_collection_id IS NOT NULL
|
||||
AND invoice_collection_id > 0
|
||||
AND deleted_at IS NULL";
|
||||
$query_result = $db->query($sql);
|
||||
while ($row = $query_result->fetch_assoc()) {
|
||||
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
|
||||
if ($invoice_collection_id > 0) {
|
||||
$invoice_collection_ids[] = $invoice_collection_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$items = [];
|
||||
@@ -686,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;
|
||||
@@ -2496,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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\customer_product_rule_service;
|
||||
use objects\logs_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
@@ -70,10 +71,29 @@ class orderItemsRoute
|
||||
$price = (int)self::getParameter('price');
|
||||
}
|
||||
}
|
||||
$order = (new orders_o())->getOrderById((int)$data['order_id']);
|
||||
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);
|
||||
}
|
||||
$customerRuleViolation = (new customer_product_rule_service())
|
||||
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
|
||||
if ($customerRuleViolation !== null) {
|
||||
(new logs_o())->add(
|
||||
'order_items',
|
||||
'global',
|
||||
1,
|
||||
$user->id,
|
||||
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
|
||||
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
|
||||
);
|
||||
$response->error($customerRuleViolation['message'], 400);
|
||||
}
|
||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
@@ -155,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
|
||||
@@ -187,7 +227,7 @@ class orderItemsRoute
|
||||
|
||||
$this->put('/order/items', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
global $response, $db;
|
||||
$this->requirePermission('edit_order_items');
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -212,19 +252,39 @@ class orderItemsRoute
|
||||
$response->error('Quantity is required', 400);
|
||||
}
|
||||
|
||||
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
|
||||
$orderItemId = (int)$data['id'];
|
||||
$orderItem = (new order_items_o())->getOrderItemById($orderItemId);
|
||||
if (!$orderItem->exists()) {
|
||||
$response->error('Order item not found', 404);
|
||||
}
|
||||
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
|
||||
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
|
||||
$orderItemContextResult = $db->query(
|
||||
"SELECT oi.order_id, oi.product_id, p.name AS product_name, p.requires_note AS product_requires_note
|
||||
FROM order_items oi
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.id = {$orderItemId}
|
||||
LIMIT 1"
|
||||
);
|
||||
$orderItemContext = $orderItemContextResult ? $orderItemContextResult->fetch_assoc() : null;
|
||||
if ($orderItemContext === null) {
|
||||
$response->error('Order item not found', 404);
|
||||
}
|
||||
if ($orderItemContext['product_id'] === null || $orderItemContext['product_name'] === null) {
|
||||
$response->error('Product not found', 404);
|
||||
}
|
||||
if (products_o::productDataRequiresOrderItemNote([
|
||||
'id' => (int)$orderItemContext['product_id'],
|
||||
'name' => (string)$orderItemContext['product_name'],
|
||||
'requires_note' => (bool)$orderItemContext['product_requires_note'],
|
||||
]) && trim((string)$data['notes']) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
|
||||
$order = (new orders_o())->getOrderById((int)$orderItem->order_id->value());
|
||||
$order = (new orders_o())->getOrderById((int)$orderItemContext['order_id']);
|
||||
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())) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,37 +20,121 @@ 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
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['customer_id'])) {
|
||||
$customerId = (int)self::getParameter('customer_id');
|
||||
try {
|
||||
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
|
||||
if ($customerObject->exists()) {
|
||||
return $customerObject;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||
// Return null
|
||||
return null;
|
||||
}
|
||||
$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()) {
|
||||
return $customerObject;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||
// Return null
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
private function getDepartmentIdIfProvided(): ?int
|
||||
{
|
||||
return $this->getOptionalPositiveIntParameter('department_id');
|
||||
}
|
||||
|
||||
private function getOptionalPositiveIntParameter(string $parameter): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['department_id'])) {
|
||||
return (int)self::getParameter('department_id');
|
||||
if (!self::isParametersSet([$parameter])) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
$value = self::getParameter($parameter);
|
||||
if ($this->isNullLikeOptionalParameter($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = null;
|
||||
if (is_int($value)) {
|
||||
$parsed = $value;
|
||||
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
|
||||
$parsed = (int)trim($value);
|
||||
} else {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
if ($parsed === null || $parsed <= 0) {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isNullLikeOptionalParameter(mixed $value): bool
|
||||
{
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
|
||||
}
|
||||
|
||||
private function isCustomerBookingSession(bool $hasAuthenticatedUser, bool $hasCustomerPermission, bool $isSubuserSession): bool
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return $customerId === null || $this->isOwnCustomerContext($customerId);
|
||||
}
|
||||
|
||||
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);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +143,7 @@ class productsRoute
|
||||
*/
|
||||
private function getCategoryIfProvided(): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['category'])) {
|
||||
return (int)self::getParameter('category');
|
||||
}
|
||||
return null;
|
||||
return $this->getOptionalPositiveIntParameter('category');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,11 +152,7 @@ class productsRoute
|
||||
*/
|
||||
private function getProductIdIfProvided(): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['id'])) {
|
||||
return (int)self::getParameter('id');
|
||||
}
|
||||
return null;
|
||||
return $this->getOptionalPositiveIntParameter('id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,12 +167,14 @@ class productsRoute
|
||||
// Check if the departmentId is set
|
||||
if ($departmentId) {
|
||||
// Apply the departments unique pricing
|
||||
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
|
||||
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
@@ -132,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'],
|
||||
@@ -190,17 +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 = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
||||
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||
$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)
|
||||
// Check if the "final_price" parameter is set, and true.
|
||||
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
|
||||
if ($useFinalPrice) {
|
||||
// Determine the products to return
|
||||
if ($category) {
|
||||
// Get products in the category
|
||||
@@ -223,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
|
||||
@@ -251,60 +356,72 @@ 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
|
||||
)
|
||||
);
|
||||
}
|
||||
// Check if the category is set in the request
|
||||
$data = $_GET ?? [];
|
||||
// Check if the category is set
|
||||
if (isset($data['category'])) {
|
||||
if ($category !== null) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
|
||||
(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($data['category']);
|
||||
$products = (new products_o())->listObjectsByCategory($category);
|
||||
if ($restrictCustomerBookingProducts) {
|
||||
$products = $this->filterProductsVisibleOnBookingForm($products);
|
||||
}
|
||||
// Check if the department_id is set
|
||||
if (isset($data['department_id'])) {
|
||||
if ($departmentId !== null) {
|
||||
// Apply the departments unique pricing
|
||||
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
|
||||
$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)
|
||||
);
|
||||
}
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
|
||||
// Check if the department_id is set
|
||||
if (isset($data['department_id'])) {
|
||||
if ($departmentId !== null) {
|
||||
// Get all product ids contained in a category attached to the department
|
||||
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
|
||||
$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,
|
||||
])
|
||||
), (int)$data['department_id'])
|
||||
(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');
|
||||
@@ -313,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.'
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
@@ -103,6 +105,9 @@ class userRoute
|
||||
}
|
||||
// Check if the required fields are set
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($data)) {
|
||||
$response->error('Invalid request body', 400);
|
||||
}
|
||||
if (!isset($data['discount'])) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set');
|
||||
@@ -122,14 +127,38 @@ class userRoute
|
||||
$response->error('No is_category set', 400);
|
||||
}
|
||||
$discount = (int)$data['discount'];
|
||||
if ($discount < 0 || $discount > 100) {
|
||||
$response->error('Discount must be between 0 and 100', 400);
|
||||
}
|
||||
$is_category = (bool)$data['is_category'];
|
||||
if ($is_category) {
|
||||
$object_id = (string)$data['object_id'];
|
||||
} else {
|
||||
$object_id = (int)$data['object_id'];
|
||||
}
|
||||
$fixed_price_is_set = array_key_exists('fixed_price', $data);
|
||||
$fixed_price = null;
|
||||
if ($fixed_price_is_set) {
|
||||
if ($data['fixed_price'] === null || $data['fixed_price'] === '') {
|
||||
$fixed_price = null;
|
||||
} else {
|
||||
$fixed_price_value = filter_var($data['fixed_price'], FILTER_VALIDATE_INT);
|
||||
if ($fixed_price_value === false) {
|
||||
$response->error('Invalid fixed price', 400);
|
||||
}
|
||||
$fixed_price = (int)$fixed_price_value;
|
||||
}
|
||||
if ($fixed_price !== null && $fixed_price < 0) {
|
||||
$response->error('Fixed price must be zero or more', 400);
|
||||
}
|
||||
if ($is_category && $fixed_price !== null) {
|
||||
$response->error('Fixed price can only be set for products', 400);
|
||||
}
|
||||
} elseif (!$is_category) {
|
||||
$fixed_price = $targetUser->getProductFixedPrice((int)$object_id);
|
||||
}
|
||||
// Set the custom price
|
||||
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category);
|
||||
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category, $fixed_price);
|
||||
try {
|
||||
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
|
||||
(int)$targetUser->id,
|
||||
@@ -145,7 +174,8 @@ class userRoute
|
||||
'route' => '/superuser/user/discounts',
|
||||
'method' => 'POST',
|
||||
'actor_user_id' => (int)$user->id,
|
||||
]
|
||||
],
|
||||
$fixed_price
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
(new logs_o())->add(
|
||||
|
||||
@@ -27,19 +27,28 @@ class usersRoute
|
||||
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
|
||||
// Return the list of users
|
||||
$users_o = new users_o();
|
||||
$response->success(
|
||||
$users_o->parseUsers(
|
||||
$users_o
|
||||
->setSearchableFields([
|
||||
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
|
||||
'id',
|
||||
'customer_number',
|
||||
'group_id',
|
||||
'display_name',
|
||||
])
|
||||
->listObjectsWithPaginationIfSet()
|
||||
)
|
||||
$limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
|
||||
$users = $users_o
|
||||
->setSearchableFields([
|
||||
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
|
||||
'id',
|
||||
'customer_number',
|
||||
'group_id',
|
||||
'display_name',
|
||||
])
|
||||
->listObjectsWithPaginationIfSet(
|
||||
null,
|
||||
$limitedEmployeeListMode['filters'],
|
||||
[],
|
||||
$limitedEmployeeListMode['additional_where']
|
||||
);
|
||||
if ($limitedEmployeeListMode['enabled']) {
|
||||
$users = $users_o->markLimitedBackofficeManagedUsers($users);
|
||||
}
|
||||
$users = $users_o->parseUsers(
|
||||
$users
|
||||
);
|
||||
$response->success($users);
|
||||
} else {
|
||||
// Log the incident
|
||||
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session');
|
||||
@@ -153,6 +162,23 @@ class usersRoute
|
||||
if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
|
||||
$data['display_name'] = null;
|
||||
}
|
||||
$targetUser = (new users_o())->getUserById((int)$data['id']);
|
||||
if (!$targetUser->exists()) {
|
||||
$response->error('User not found', 404);
|
||||
}
|
||||
|
||||
if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) {
|
||||
$currentCustomerNumber = (string)$targetUser->customer_number->value();
|
||||
if ((string)$data['customer_number'] !== $currentCustomerNumber) {
|
||||
$response->error('Limited backoffice managed users cannot change customer number.', 403);
|
||||
}
|
||||
|
||||
if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) {
|
||||
$response->error('Limited backoffice managed users cannot change role.', 403);
|
||||
}
|
||||
|
||||
$data['role'] = null;
|
||||
}
|
||||
// If the role is set, require the edit_user_role permission
|
||||
if ($data['role']) {
|
||||
$this->requirePermission('edit_user_role');
|
||||
@@ -207,4 +233,59 @@ class usersRoute
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
|
||||
*/
|
||||
private function limitedBackofficeEmployeeListMode(users_o $users): array
|
||||
{
|
||||
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
|
||||
$filters = $this->fromQuery('filters');
|
||||
|
||||
if ($filters === null || $filters === '') {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'filters' => null,
|
||||
'additional_where' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$filterArray = $users->filter_string_to_array($filters);
|
||||
$customerNumberFilter = $filterArray['customer_number'] ?? null;
|
||||
$isEmployeeFilter = $customerNumberFilter === '0'
|
||||
|| $customerNumberFilter === 0
|
||||
|| (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' => $enabled ? $filters : null,
|
||||
'additional_where' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// $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),
|
||||
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` (
|
||||
`is_category` TINYINT(1) NOT NULL,
|
||||
`object_id` VARCHAR(64) NOT NULL,
|
||||
`discount` INT NOT NULL,
|
||||
`fixed_price` INT NULL DEFAULT NULL,
|
||||
`effective_from` DATETIME NOT NULL,
|
||||
`effective_to` DATETIME NULL,
|
||||
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -85,6 +85,65 @@ it('previews monthly split changes without moving orders or creating collections
|
||||
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
|
||||
});
|
||||
|
||||
it('previews only explicit monthly split invoice collection ids', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'preview-scope');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Preview Monthly Split Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
$ignoredCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
$targetMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-03-15 10:00:00',
|
||||
]);
|
||||
$targetAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-04-02 10:00:00',
|
||||
]);
|
||||
$ignoredMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-03-16 10:00:00',
|
||||
]);
|
||||
$ignoredAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-04-03 10:00:00',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||
|
||||
$response = api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => [$targetCollection['id']],
|
||||
'preview' => true,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $response->data();
|
||||
expect($payload['processed_count'] ?? null)->toBe(1)
|
||||
->and($payload['changed_count'] ?? null)->toBe(1)
|
||||
->and($payload['changed'][0]['invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
|
||||
});
|
||||
|
||||
it('splits a selected March and April collected invoice into monthly collections', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
|
||||
|
||||
@@ -139,6 +198,74 @@ it('splits a selected March and April collected invoice into monthly collections
|
||||
}
|
||||
});
|
||||
|
||||
it('splits only explicit monthly split invoice collection ids', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'scope');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$targetCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'created_at' => '2096-03-01 00:00:01',
|
||||
]);
|
||||
$ignoredCollection = api_fixtures()->createInvoiceCollection([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'created_at' => '2096-03-01 00:00:01',
|
||||
]);
|
||||
$targetMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-03-15 10:00:00',
|
||||
]);
|
||||
$targetAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $targetCollection['id'],
|
||||
'created_at' => '2096-04-02 10:00:00',
|
||||
]);
|
||||
$ignoredMarchOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-03-16 10:00:00',
|
||||
]);
|
||||
$ignoredAprilOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'invoice_collection_id' => $ignoredCollection['id'],
|
||||
'created_at' => '2096-04-03 10:00:00',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||
$createdCollectionIds = [];
|
||||
|
||||
try {
|
||||
$response = api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => [$targetCollection['id']],
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $response->data();
|
||||
$createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []);
|
||||
$aprilCollectionId = (int)($createdCollectionIds[0] ?? 0);
|
||||
|
||||
expect($payload['processed_count'] ?? null)->toBe(1)
|
||||
->and($payload['changed_count'] ?? null)->toBe(1)
|
||||
->and($aprilCollectionId)->toBeGreaterThan(0)
|
||||
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId)
|
||||
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
|
||||
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
|
||||
} finally {
|
||||
monthly_split_cleanup_collections($createdCollectionIds);
|
||||
}
|
||||
});
|
||||
|
||||
it('sets closed_at to month end when split month has ended', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
|
||||
|
||||
@@ -345,3 +472,27 @@ it('rejects invalid monthly split date ranges', function (): void {
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
});
|
||||
|
||||
it('rejects invalid explicit monthly split invoice collection ids', function (): void {
|
||||
api_test_covers('POST /collected-invoices/split-by-month', 'invalid-scope');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
|
||||
|
||||
api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => ['not-a-number'],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
api_client()->post('/collected-invoices/split-by-month', [
|
||||
'dateFrom' => '2096-03-01',
|
||||
'dateTo' => '2096-04-30',
|
||||
'invoice_collection_ids' => [],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function daily_report_product_from_overview(array $overview, int $productId): array
|
||||
{
|
||||
foreach (($overview['products'] ?? []) as $product) {
|
||||
if ((int)($product['product_id'] ?? 0) === $productId) {
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
it('stores clears and permission-gates department daily report product targets', function (): void {
|
||||
api_test_covers('PUT /departments/daily-reports/product-targets', 'happy');
|
||||
api_test_covers('GET /departments/daily-reports/overview', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Daily Report Product Target ' . uniqid('', false),
|
||||
]);
|
||||
$departmentId = (int)$department['id'];
|
||||
$editorPermissions = [
|
||||
'list_department_daily_reports',
|
||||
'list_bookings',
|
||||
'set_department_daily_report_product_targets',
|
||||
'department_access_' . $departmentId,
|
||||
];
|
||||
$editorSession = api_fixtures()->createUserSession($editorPermissions);
|
||||
$viewerSession = api_fixtures()->createUserSession([
|
||||
'list_department_daily_reports',
|
||||
'list_bookings',
|
||||
'department_access_' . $departmentId,
|
||||
]);
|
||||
|
||||
try {
|
||||
$saveResponse = api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 47.55,
|
||||
], $editorSession['headers']);
|
||||
|
||||
$saveResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($saveResponse->data())->toMatchArray([
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 47.6,
|
||||
]);
|
||||
|
||||
$overviewResponse = api_client()->get(
|
||||
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
|
||||
$editorSession['headers']
|
||||
);
|
||||
|
||||
$overviewResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$editorProduct = daily_report_product_from_overview($overviewResponse->data(), 24);
|
||||
expect($editorProduct['target_percentage'])->toBe(47.6);
|
||||
expect($editorProduct['target_department_id'])->toBe($departmentId);
|
||||
|
||||
$viewerOverviewResponse = api_client()->get(
|
||||
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
|
||||
$viewerSession['headers']
|
||||
);
|
||||
|
||||
$viewerOverviewResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$viewerProduct = daily_report_product_from_overview($viewerOverviewResponse->data(), 24);
|
||||
expect($viewerProduct['target_percentage'])->toBeNull();
|
||||
expect($viewerProduct['target_department_id'])->toBeNull();
|
||||
|
||||
$clearResponse = api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => null,
|
||||
], $editorSession['headers']);
|
||||
|
||||
$clearResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($clearResponse->data())->toMatchArray([
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => null,
|
||||
]);
|
||||
|
||||
$clearedOverviewResponse = api_client()->get(
|
||||
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
|
||||
$editorSession['headers']
|
||||
);
|
||||
|
||||
$clearedProduct = daily_report_product_from_overview($clearedOverviewResponse->data(), 24);
|
||||
expect($clearedProduct['target_percentage'])->toBeNull();
|
||||
expect($clearedProduct['target_department_id'])->toBeNull();
|
||||
} finally {
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => null,
|
||||
], $editorSession['headers']);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects product target updates without permission access or valid input', function (): void {
|
||||
api_test_covers('PUT /departments/daily-reports/product-targets', 'auth');
|
||||
api_test_covers('PUT /departments/daily-reports/product-targets', 'failure');
|
||||
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$departmentId = (int)$department['id'];
|
||||
|
||||
$missingPermissionSession = api_fixtures()->createUserSession([
|
||||
'department_access_' . $departmentId,
|
||||
]);
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 50,
|
||||
], $missingPermissionSession['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['set_department_daily_report_product_targets']);
|
||||
|
||||
$missingDepartmentAccessSession = api_fixtures()->createUserSession([
|
||||
'set_department_daily_report_product_targets',
|
||||
]);
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 50,
|
||||
], $missingDepartmentAccessSession['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . $departmentId]);
|
||||
|
||||
$editorSession = api_fixtures()->createUserSession([
|
||||
'set_department_daily_report_product_targets',
|
||||
'department_access_' . $departmentId,
|
||||
]);
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 999999,
|
||||
'target_percentage' => 50,
|
||||
], $editorSession['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid daily report product_id');
|
||||
|
||||
api_client()->put('/departments/daily-reports/product-targets', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => 24,
|
||||
'target_percentage' => 101,
|
||||
], $editorSession['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Parameter target_percentage must be between 0 and 100');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -231,6 +264,7 @@ it('updates departments through the real endpoint', function (): void {
|
||||
'description' => 'Updated description',
|
||||
'order_priority' => 5,
|
||||
'archived' => true,
|
||||
'custom_pricing_only' => true,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
@@ -246,6 +280,7 @@ it('updates departments through the real endpoint', function (): void {
|
||||
expect($row['description'] ?? null)->toBe('Updated description');
|
||||
expect((int)($row['order_priority'] ?? 0))->toBe(5);
|
||||
expect((int)($row['archived'] ?? 0))->toBe(1);
|
||||
expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid department update requests', function (): void {
|
||||
@@ -299,6 +334,42 @@ it('lists department categories for a department', function (): void {
|
||||
->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||
});
|
||||
|
||||
it('lets customer booking sessions list department categories without the management permission', function (): void {
|
||||
api_test_covers('GET /departments/categories', 'auth');
|
||||
|
||||
$customerSession = api_fixtures()->createUserSession(['user']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$category = api_fixtures()->createCategory([
|
||||
'name' => 'Customer Department Category',
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
|
||||
$customerResponse = api_client()->get('/departments/categories?id=' . $department['id'], $customerSession['headers']);
|
||||
|
||||
$customerResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($customerResponse->data())
|
||||
->toBeArray()
|
||||
->toHaveCount(1)
|
||||
->and($customerResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||
|
||||
$subuserSession = api_fixtures()->createSubuserSession((int)$customerSession['user']['customer_number'], []);
|
||||
$subuserResponse = api_client()->get('/departments/categories?id=' . $department['id'], $subuserSession['headers']);
|
||||
|
||||
$subuserResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($subuserResponse->data())
|
||||
->toBeArray()
|
||||
->toHaveCount(1)
|
||||
->and($subuserResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
|
||||
});
|
||||
|
||||
it('rejects invalid department category requests', function (): void {
|
||||
api_test_covers('GET /departments/categories', 'failure');
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function error_report_api_payload(array $overrides = []): array
|
||||
{
|
||||
return array_replace_recursive([
|
||||
'before_error' => 'Opening the orders page',
|
||||
'expected' => 'The orders should load',
|
||||
'actual' => 'The page showed an error',
|
||||
'data_collection_accepted' => true,
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
'route_path' => '/admin/orders',
|
||||
'page_url' => 'https://app.example.test/admin/orders',
|
||||
'release_trace_id' => 'trace-error-report-test',
|
||||
'frontend_version' => 'frontend-test',
|
||||
'api_version' => 'api-test',
|
||||
'request_errors' => [
|
||||
['method' => 'GET', 'url' => '/orders', 'statusCode' => 500],
|
||||
],
|
||||
'vue_errors' => [
|
||||
['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']],
|
||||
],
|
||||
'context' => [
|
||||
'viewport' => ['width' => 1280, 'height' => 720],
|
||||
'user_agent' => 'ErrorReportsApiTest',
|
||||
'captured_at' => '2026-07-06T10:00:00.000Z',
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function error_report_api_cleanup(array $report): void
|
||||
{
|
||||
$id = (int)($report['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
api_fixtures()->cleanupDeleteById('error_reports', $id);
|
||||
}
|
||||
}
|
||||
|
||||
it('creates error reports when screenshot capture failed', function (): void {
|
||||
api_test_covers('POST /error-reports', 'happy');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => null,
|
||||
'context' => [
|
||||
'screenshot_attachment' => ['status' => 'capture_failed'],
|
||||
],
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['answers']['before_error'])->toBe('Opening the orders page');
|
||||
expect($report['request_error_count'])->toBe(1);
|
||||
expect($report['vue_error_count'])->toBe(1);
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'capture_failed',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
|
||||
it('creates error reports when an optional screenshot payload is invalid', function (): void {
|
||||
api_test_covers('POST /error-reports', 'invalid optional screenshot');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'),
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'invalid',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function order_booking_create_payload(array $customer, array $department, array $product, string $reference): array
|
||||
{
|
||||
return [
|
||||
'customer_number' => (int)$customer['customer_number'],
|
||||
'department' => (int)$department['id'],
|
||||
'reg_1' => $reference,
|
||||
'datetime' => '2026-07-07 10:00:00',
|
||||
'note' => '',
|
||||
'reference' => $reference,
|
||||
'po' => '',
|
||||
'pickup' => false,
|
||||
'items' => [
|
||||
[
|
||||
'id' => (int)$product['id'],
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function order_booking_create_department(string $name): array
|
||||
{
|
||||
$branding = api_fixtures()->createBranding([
|
||||
'name' => $name . ' Brand',
|
||||
'address' => 'API Booking Street 1',
|
||||
]);
|
||||
|
||||
return api_fixtures()->createDepartment([
|
||||
'name' => $name,
|
||||
'branding' => (int)$branding['id'],
|
||||
]);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['user']);
|
||||
$department = order_booking_create_department('Own Booking Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Own Booking Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($session['user'], $department, $product, 'OWNBOOK1'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||
expect($bookingId)->toBeGreaterThan(0);
|
||||
|
||||
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
|
||||
expect($row)->not->toBeNull();
|
||||
expect((int)($row['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']);
|
||||
|
||||
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');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']);
|
||||
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], []);
|
||||
$department = order_booking_create_department('Subuser Booking Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($customer, $department, $product, 'SUBBOOK1'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['add_own_bookings']);
|
||||
});
|
||||
|
||||
it('lets subusers create own customer order bookings with the bookings add node', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'auth');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer With Add']);
|
||||
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], ['BOOKINGS_ADD']);
|
||||
$department = order_booking_create_department('Subuser Booking Add Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Add Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($customer, $department, $product, 'SUBBOOK2'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||
expect($bookingId)->toBeGreaterThan(0);
|
||||
|
||||
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
|
||||
expect($row)->not->toBeNull();
|
||||
expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']);
|
||||
|
||||
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||
});
|
||||
|
||||
it('still requires elevated access for creating another customer order booking', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'auth');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['user']);
|
||||
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']);
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']);
|
||||
$product = api_fixtures()->createProduct(['name' => 'Other Booking Product']);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['add_bookings']);
|
||||
});
|
||||
|
||||
it('lets department-scoped users create order bookings for another customer', function (): void {
|
||||
api_test_covers('POST /order-bookings', 'happy');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']);
|
||||
$department = order_booking_create_department('Department Scoped Booking Department');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']);
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'add_bookings',
|
||||
'department_access_' . $department['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/order-bookings',
|
||||
order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'),
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$bookingId = (int)($response->data()['id'] ?? 0);
|
||||
expect($bookingId)->toBeGreaterThan(0);
|
||||
|
||||
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
|
||||
});
|
||||
@@ -4,6 +4,73 @@ declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function create_order_item_rule_fixture(array $customerAttributes = []): array
|
||||
{
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']);
|
||||
foreach ($customerAttributes as $attribute) {
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute);
|
||||
}
|
||||
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'RULE-CHECK',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
return [
|
||||
'customer' => $customer,
|
||||
'department' => $department,
|
||||
'order' => $order,
|
||||
'session' => $session,
|
||||
];
|
||||
}
|
||||
|
||||
function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse
|
||||
{
|
||||
return api_client()->post('/order/items', array_merge([
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
], $overrides), $headers);
|
||||
}
|
||||
|
||||
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
|
||||
VALUES (?, 0, ?, ?)'
|
||||
);
|
||||
$productIdText = (string)$productId;
|
||||
$statement->bind_param('isi', $userId, $productIdText, $percentage);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
api_fixtures()->cleanupDeleteWhere('price_overrides', [
|
||||
'user_id' => $userId,
|
||||
'is_category' => 0,
|
||||
'product_or_category_id' => $productIdText,
|
||||
]);
|
||||
}
|
||||
|
||||
function custom_pricing_only_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('requires notes when adding the extraordinary chemistry product to an order', function (): void {
|
||||
api_test_covers('POST /order/items', 'validation');
|
||||
|
||||
@@ -15,7 +82,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
'reference' => 'NOTE-REQUIRED',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902701,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
@@ -49,6 +115,146 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
||||
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
||||
});
|
||||
|
||||
it('uses a product fixed price instead of the best discount when adding an order item', function (): void {
|
||||
api_test_covers('POST /order/items', 'pricing');
|
||||
api_test_covers('GET /products', 'pricing');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Fixed Price Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Fixed Price Cashier']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Fixed Price Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Fixed Price Product',
|
||||
'price' => 1000,
|
||||
'category' => $category['id'],
|
||||
'apply_category_discount' => 1,
|
||||
]);
|
||||
|
||||
api_fixtures()->createPriceOverride([
|
||||
'user_id' => $customer['id'],
|
||||
'is_category' => 1,
|
||||
'product_or_category_id' => (string)$category['id'],
|
||||
'percentage' => 80,
|
||||
]);
|
||||
api_fixtures()->createPriceOverride([
|
||||
'user_id' => $customer['id'],
|
||||
'is_category' => 0,
|
||||
'product_or_category_id' => (string)$product['id'],
|
||||
'percentage' => 10,
|
||||
'fixed_price' => 350,
|
||||
]);
|
||||
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'reference' => 'FIXED-PRICE',
|
||||
]);
|
||||
$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'],
|
||||
$session['headers']
|
||||
);
|
||||
$productResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($productResponse->data()['price'] ?? null)->toBe(350);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data()['price'] ?? null)->toBe(350);
|
||||
});
|
||||
|
||||
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer_rules');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'ONLY-TANK',
|
||||
]);
|
||||
$washProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Forvogn',
|
||||
'price' => 649,
|
||||
'category' => 4,
|
||||
]);
|
||||
$tankCleaningProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'price' => 299,
|
||||
'category' => 5,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
api_client()
|
||||
->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $washProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $tankCleaningProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
|
||||
});
|
||||
|
||||
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer_rules');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'REGULAR-WASH',
|
||||
]);
|
||||
$washProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Forvogn',
|
||||
'price' => 649,
|
||||
'category' => 4,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $washProduct['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
|
||||
});
|
||||
|
||||
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
||||
api_test_covers('PUT /order/items', 'validation');
|
||||
|
||||
@@ -63,7 +269,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902702,
|
||||
'name' => 'API Note Required Product',
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 199,
|
||||
'requires_note' => 1,
|
||||
]);
|
||||
@@ -75,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']);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
|
||||
|
||||
api_client()
|
||||
->put('/order/items', [
|
||||
@@ -95,7 +301,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
api_test_covers('GET /products', 'happy');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902703,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
@@ -111,3 +316,202 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
|
||||
expect($response->data()['requires_note'] ?? null)->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary truck wash',
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Drying add-on',
|
||||
'category' => 4,
|
||||
'price' => 50,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||
'notes' => 'Addon customer rule check',
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture();
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary unrestricted truck wash',
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Unrestricted add-on',
|
||||
'category' => 4,
|
||||
'price' => 50,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('blocks related addon order items for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']);
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary related truck wash',
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Related extra brush',
|
||||
'price' => 35,
|
||||
]);
|
||||
$primaryItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $primaryProduct['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 200,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture([$attribute]);
|
||||
$product = api_fixtures()->createProduct($productAttributes);
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
})->with([
|
||||
'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]],
|
||||
'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]],
|
||||
'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]],
|
||||
]);
|
||||
|
||||
it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['onlyTankCleaning']);
|
||||
$nonTankProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Exterior truck wash',
|
||||
'price' => 180,
|
||||
]);
|
||||
$tankProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Tank cleaning',
|
||||
'category' => 5,
|
||||
'price' => 300,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
|
||||
post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
|
||||
api_test_covers('GET /products', 'happy');
|
||||
api_test_covers('POST /order/items', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Custom Pricing Products',
|
||||
'custom_pricing_only' => 1,
|
||||
]);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Custom Pricing Missing Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 12345,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
|
||||
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
|
||||
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'list_products',
|
||||
'add_order_items',
|
||||
'department_access_' . (int)$department['id'],
|
||||
]);
|
||||
|
||||
$productResponse = api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id']
|
||||
. '&department_id=' . (int)$department['id']
|
||||
. '&customer_id=' . (int)$customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
$productResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($productResponse->body)->not->toContain('12345');
|
||||
expect($productResponse->body)->not->toContain('3333');
|
||||
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
|
||||
api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id']
|
||||
. '&department_id=' . (int)$otherDepartment['id'],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'CUSTOM-ONLY-ORDER',
|
||||
]);
|
||||
|
||||
$orderItem = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
], $session['headers']);
|
||||
|
||||
$orderItem
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user