Compare commits

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

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

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
Jeppe Bundgaard 0cca597fdc Fix XLVask usage import dates
Fix XLVask usage-log import metadata and period-scoped Selvvask automation.
2026-07-06 23:49:28 +02:00
Jeppe B 709c6acbba Fix product null department permissions
Treats null-like optional product query params as omitted and avoids department_access_0 permission checks.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard ed2736e528 Fix product null department permissions 2026-07-06 19:56:07 +02:00
Jeppe B c7f5c73a9e Merge pull request #303 from copenhagentruckwash/codex/daily-report-product-targets-api
[codex] Add daily report product target API
2026-07-06 19:35:37 +02:00
Jeppe Bundgaard c10af48954 Add daily report product target API 2026-07-06 18:52:24 +02:00
Jeppe Bundgaard 7ac5c5585b Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe B 8544ce0a18 Merge pull request #297 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price overrides
2026-07-06 17:23:06 +02:00
Jeppe Bundgaard 614715822f Fix backend merge fallout for booking and limited employees 2026-07-06 17:16:22 +02:00
Jeppe Bundgaard 1da02e2486 Fix limited backoffice price save reset 2026-07-06 17:13:04 +02:00
Jeppe B 742b15116d Merge pull request #295 from copenhagentruckwash/fix/economic-ean-transfer
Fix e-conomic EAN customer transfer
2026-07-06 17:00:57 +02:00
Jeppe Bundgaard e0ae74bdc2 Merge remote-tracking branch 'origin/master' into codex/customer-product-fixed-price-overrides 2026-07-06 17:00:52 +02:00
Jeppe Bundgaard 08dc803b3e Make limited backoffice employees regular employees 2026-07-06 16:59:32 +02:00
Jeppe Bundgaard 248a901f24 Merge master into fixed price override branch 2026-07-06 16:54:01 +02:00
Jeppe Bundgaard 8bbdf9daf5 Require booking add node for subuser booking creation 2026-07-06 16:48:07 +02:00
Jeppe B c089186046 Merge pull request #302 from copenhagentruckwash/codex/customer-orderbooking-create-without-permission
Allow customer order booking creation without booking permission
2026-07-06 16:39:17 +02:00
Jeppe Bundgaard d9eacf6f84 Deduplicate limited backoffice price products 2026-07-06 16:28:08 +02:00
Jeppe Bundgaard f02dfd8c9c Add customer product fixed price overrides 2026-07-06 12:52:33 +02:00
Jeppe Bundgaard db1b9a2c96 Fix e-conomic EAN customer transfer 2026-07-06 11:34:23 +02:00
148 changed files with 14283 additions and 2668 deletions
+927 -74
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_schedule
{
public static function normalize(array $schedule): array
{
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
if ($type !== 'interval') {
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
}
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
if ($seconds < 30 || $seconds > 2678400) {
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
}
return [
'type' => 'interval',
'seconds' => $seconds,
];
}
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
{
$normalized = self::normalize($schedule);
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
? strtotime($anchorDateTime)
: false;
$base = $anchor !== false ? (int)$anchor : $now;
$next = $base + (int)$normalized['seconds'];
if ($next <= $now) {
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
$next += $missed * (int)$normalized['seconds'];
}
return date('Y-m-d H:i:s', $next);
}
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
{
$normalized = self::normalize($schedule);
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
return self::nextRunAt($normalized, $lastRunAt, $now);
}
if ($legacyLastRun !== null && $legacyLastRun > 0) {
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
}
return date('Y-m-d H:i:s', $now);
}
}
@@ -0,0 +1,464 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class cron_scheduler
{
private cron_task_registry $registry;
private string $lock_owner;
public function __construct(?cron_task_registry $registry = null)
{
$this->registry = $registry ?? new cron_task_registry();
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
}
public function listTasks(): array
{
$this->ensureReady();
$this->syncDefinitions();
$states = $this->stateRows();
$estimates = $this->durationEstimates();
$tasks = [];
$now = time();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
? $state['schedule']
: $definition->schedule;
$nextRunAt = $state['next_run_at'] ?? null;
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
}
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
$tasks[] = $task;
}
return [
'tasks' => $tasks,
'summary' => [
'total' => count($tasks),
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
],
];
}
public function listRuns(?string $task_id = null, int $limit = 50): array
{
$this->ensureReady();
$limit = max(1, min(200, $limit));
$where = '';
if ($task_id !== null && trim($task_id) !== '') {
$where = "WHERE task_id = " . $this->sql($task_id);
}
return $this->fetchAll(
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
);
}
public function runDue(string $source = 'automatic'): array
{
$this->ensureReady();
$this->syncDefinitions();
$ran = [];
$now = time();
$states = $this->stateRows();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
continue;
}
$nextRunAt = (string)($state['next_run_at'] ?? '');
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
continue;
}
try {
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => $definition->id,
'module' => $definition->module,
'source' => $source,
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
return [
'ran' => $ran,
'count' => count($ran),
];
}
public function runTask(
string $task_id_or_legacy_name,
string $source = 'manual',
?int $actor_user_id = null,
bool $force = false,
?string $scheduled_for = null
): array {
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if (!$this->claimLock($definition)) {
throw new RuntimeException('Cron task is already running.');
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at);
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
$status = 'succeeded';
$summary = [];
$error_message = null;
$output = '';
try {
if (function_exists('set_time_limit')) {
@set_time_limit($definition->timeout_seconds + 30);
}
$this->ensureLegacyFunctionsLoaded($definition);
if (!is_callable($definition->handler)) {
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
}
ob_start();
$result = call_user_func($definition->handler);
$output = (string)ob_get_clean();
$summary = is_array($result) ? $result : [];
} catch (Throwable $throwable) {
if (ob_get_level() > 0) {
$output .= (string)ob_get_clean();
}
$status = 'failed';
$error_message = $throwable->getMessage();
}
$completed = microtime(true);
$duration_ms = (int)round(($completed - $started) * 1000);
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
$status = 'timed_out';
$error_message = 'Task exceeded its configured timeout window.';
}
if ($output !== '') {
$summary['output'] = substr($output, 0, 8000);
}
$completed_at = date('Y-m-d H:i:s', (int)$completed);
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at);
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
return $run;
}
public function updateTaskConfig(string $task_id, array $config): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$updates = [];
if (array_key_exists('enabled', $config)) {
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
}
if (array_key_exists('schedule', $config)) {
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
}
if ($updates !== []) {
$this->query(
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
);
}
return $this->listTasks();
}
private function ensureReady(): void
{
cron_schema_bootstrap::ensureTables();
}
private function syncDefinitions(): void
{
$now = time();
foreach ($this->registry->definitions() as $definition) {
$row = $this->fetchOne(
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
);
if ($row !== null) {
continue;
}
$legacyLastRun = $this->legacyLastRun($definition);
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
$this->query(
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. ($definition->enabled ? '1' : '0') . ', NULL, '
. $this->sql($nextRunAt)
. ")"
);
}
}
private function legacyLastRun(cron_task_definition $definition): ?int
{
if ($definition->legacy_name === null || !defined('redis')) {
return null;
}
try {
$last_run = redis->get_last_crond_run($definition->legacy_name);
return $last_run !== null ? (int)$last_run : null;
} catch (Throwable) {
return null;
}
}
private function claimLock(cron_task_definition $definition): bool
{
$now = date('Y-m-d H:i:s');
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
$this->query(
"UPDATE cron_task_state
SET locked_until = " . $this->sql($locked_until) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
);
return $this->affectedRows() === 1;
}
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
{
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
if ($schedule === []) {
$schedule = $definition->schedule;
}
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
if ($status !== 'succeeded') {
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
}
$this->query(
"UPDATE cron_task_state
SET last_run_at = " . $this->sql($completed_at) . ",
next_run_at = " . $this->sql($nextRunAt) . ",
locked_until = NULL,
lock_owner = NULL,
current_run_id = NULL,
last_status = " . $this->sql($status) . ",
last_error = " . $this->nullableSql($error_message) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
if ($definition->legacy_name !== null && defined('redis')) {
try {
redis->set_last_crond_run($definition->legacy_name, time());
} catch (Throwable) {
}
}
}
private function createRun(
cron_task_definition $definition,
string $source,
?int $actor_user_id,
?string $scheduled_for,
string $started_at
): int {
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. $this->sql($source) . ", 'running', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->nullableSql($scheduled_for) . ', '
. $this->sql($started_at) . ', '
. $this->sql($this->lock_owner)
. ")"
);
return $this->insertId();
}
private function completeRun(
int $run_id,
string $status,
string $completed_at,
int $duration_ms,
array $summary,
?string $error_message
): void {
$this->query(
"UPDATE cron_task_runs
SET status = " . $this->sql($status) . ",
completed_at = " . $this->sql($completed_at) . ",
duration_ms = " . (string)$duration_ms . ",
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
error_message = " . $this->nullableSql($error_message) . "
WHERE id = " . (string)$run_id
);
}
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
{
if (function_exists($definition->handler)) {
return;
}
if (!defined('WD')) {
return;
}
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
}
require_once WD . '/cron/Cron.php';
}
/**
* @return array<string, array<string, mixed>>
*/
private function stateRows(): array
{
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
$states = [];
foreach ($rows as $row) {
$row['enabled'] = (bool)$row['enabled'];
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
$states[(string)$row['task_id']] = $row;
}
return $states;
}
/**
* @return array<string, int>
*/
private function durationEstimates(): array
{
$rows = $this->fetchAll(
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
FROM (
SELECT task_id, duration_ms
FROM cron_task_runs
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
ORDER BY id DESC
LIMIT 500
) recent_runs
GROUP BY task_id"
);
$estimates = [];
foreach ($rows as $row) {
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
}
return $estimates;
}
private function decodeJson(mixed $json): array
{
if (!is_string($json) || trim($json) === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function fetchOne(string $sql): ?array
{
$rows = $this->fetchAll($sql);
return $rows[0] ?? null;
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function nullableSql(?string $value): string
{
return $value === null ? 'NULL' : $this->sql($value);
}
private function affectedRows(): int
{
global $db;
return (int)$db->conn()->affected_rows;
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
}
@@ -0,0 +1,66 @@
<?php
namespace classes;
class cron_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_state (
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
module VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
schedule_json LONGTEXT NULL,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
locked_until DATETIME NULL,
lock_owner VARCHAR(191) NULL,
current_run_id BIGINT UNSIGNED NULL,
last_status VARCHAR(32) NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_state_next_run (enabled, next_run_at),
KEY idx_cron_task_state_lock (locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_runs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(191) NOT NULL,
module VARCHAR(64) NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
actor_user_id INT NULL,
scheduled_for DATETIME NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
duration_ms INT UNSIGNED NULL,
summary_json LONGTEXT NULL,
error_message TEXT NULL,
lock_owner VARCHAR(191) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_runs_task_created (task_id, created_at),
KEY idx_cron_task_runs_status_created (status, created_at),
KEY idx_cron_task_runs_module_created (module, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_definition
{
public string $id;
public string $name;
public string $description;
public string $module;
public string $handler;
public array $schedule;
public bool $enabled;
public int $timeout_seconds;
public int $estimated_duration_ms;
public int $priority;
public ?string $legacy_name;
public function __construct(array $definition)
{
$this->id = self::requiredString($definition, 'id');
$this->name = self::requiredString($definition, 'name');
$this->description = (string)($definition['description'] ?? '');
$this->module = self::requiredString($definition, 'module');
$this->handler = self::requiredString($definition, 'handler');
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
$this->enabled = (bool)($definition['enabled'] ?? true);
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
$this->priority = (int)($definition['priority'] ?? 100);
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
}
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
}
}
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
{
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
? $state['schedule']
: $this->schedule;
$enabled = array_key_exists('enabled', $state ?? [])
? (bool)$state['enabled']
: $this->enabled;
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'module' => $this->module,
'handler' => $this->handler,
'schedule' => $schedule,
'default_schedule' => $this->schedule,
'enabled' => $enabled,
'default_enabled' => $this->enabled,
'timeout_seconds' => $this->timeout_seconds,
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
'priority' => $this->priority,
'legacy_name' => $this->legacy_name,
'last_run_at' => $state['last_run_at'] ?? null,
'next_run_at' => $state['next_run_at'] ?? null,
'locked_until' => $state['locked_until'] ?? null,
'lock_owner' => $state['lock_owner'] ?? null,
'current_run_id' => $state['current_run_id'] ?? null,
'last_status' => $state['last_status'] ?? null,
'last_error' => $state['last_error'] ?? null,
];
}
private static function requiredString(array $definition, string $key): string
{
$value = trim((string)($definition[$key] ?? ''));
if ($value === '') {
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
}
return $value;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_registry
{
private string $modules_root;
/** @var array<string, cron_task_definition>|null */
private ?array $definitions = null;
public function __construct(?string $modules_root = null)
{
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
}
/**
* @return array<string, cron_task_definition>
*/
public function definitions(): array
{
if ($this->definitions !== null) {
return $this->definitions;
}
$definitions = [];
foreach ($this->definitionFiles() as $file) {
$module_definitions = require $file;
if (!is_array($module_definitions)) {
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
}
foreach ($module_definitions as $definition) {
$task = new cron_task_definition($definition);
if (isset($definitions[$task->id])) {
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
}
$definitions[$task->id] = $task;
}
}
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
if ($left->priority !== $right->priority) {
return $left->priority <=> $right->priority;
}
return strcmp($left->id, $right->id);
});
$this->definitions = $definitions;
return $definitions;
}
public function get(string $id_or_legacy_name): ?cron_task_definition
{
$normalized = trim($id_or_legacy_name);
if ($normalized === '') {
return null;
}
$definitions = $this->definitions();
if (isset($definitions[$normalized])) {
return $definitions[$normalized];
}
foreach ($definitions as $definition) {
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
return $definition;
}
}
return null;
}
/**
* @return array<int, string>
*/
private function definitionFiles(): array
{
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
sort($files, SORT_STRING);
return $files;
}
}
@@ -137,6 +137,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) { if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400); 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 protected function normalizePositiveInt(mixed $value): ?int
@@ -12,6 +12,7 @@ class customer_product_rule_service
private const ADDON_CATEGORY_ID = 4; private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5; private const TANK_CLEANING_CATEGORY_ID = 5;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
/** /**
* @return array{rule:string,message:string}|null * @return array{rule:string,message:string}|null
@@ -52,7 +53,7 @@ class customer_product_rule_service
} }
if ($customer->doesUserHaveAttribute('restrictSpotFree') if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) { && $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
return $this->violation('restrictSpotFree'); return $this->violation('restrictSpotFree');
} }
@@ -101,6 +102,15 @@ class customer_product_rule_service
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']); 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 private function searchableProductText(products_o $product, string $categoryName): string
{ {
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName)); return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
@@ -0,0 +1,44 @@
<?php
namespace classes;
/**
* Ensures additive schema for department-scoped customer price overrides.
*/
class department_customer_price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`user_id` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,353 @@
<?php
namespace classes;
use objects\department_customer_price_overrides_o;
use objects\departments_o;
use objects\products_o;
use objects\users_o;
class department_customer_pricing_service
{
/**
* @return array<string, mixed>
*/
public function getPricing(int $departmentId, int $userId): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
return [
'department' => $department,
'customer' => $customer,
'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updatePricing(int $departmentId, int $userId, array $payload): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
}
$overrides = $payload['overrides'] ?? null;
if (!is_array($overrides)) {
throw new limited_backoffice_exception('Overrides are required.', 400);
}
$normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o();
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$normalizedKeys = [];
foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
}
global $db;
$db->conn()->begin_transaction();
try {
$db->query(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
);
foreach ($normalized as $override) {
$overrideObject->setPrice(
$departmentId,
$customer['id'],
(bool)$override['is_category'],
$override['product_or_category_id'],
(int)$override['percentage'],
$override['fixed_price']
);
}
$db->conn()->commit();
} catch (\Throwable) {
$db->conn()->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
}
foreach ($normalized as $override) {
$this->recordVersion($customer, $departmentId, $override);
}
foreach ($existingOverrides as $existingOverride) {
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
if (isset($normalizedKeys[$key])) {
continue;
}
$this->recordVersion($customer, $departmentId, [
'is_category' => (bool)$existingOverride['is_category'],
'product_or_category_id' => $existingOverride['product_or_category_id'],
'percentage' => 0,
'fixed_price' => null,
]);
}
return $this->getPricing($departmentId, $customer['id']);
}
/**
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function department(int $departmentId): array
{
$department = (new departments_o())->getDepartmentById($departmentId);
if (!is_array($department) || empty($department)) {
throw new limited_backoffice_exception('Department not found', 404);
}
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'description' => (string)($department['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{id:int,customer_number:int,display_name:string}
*/
private function customer(int $userId): array
{
$customer = (new users_o())->getUserById($userId);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return [
'id' => (int)$customer->id,
'customer_number' => (int)$customer->customer_number->value(),
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
];
}
/**
* @param array<string, mixed> $department
*/
private function assertEnabled(array $department): void
{
if (!($department['custom_pricing_only'] ?? false)) {
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
'message' => 'Department customer pricing is disabled.',
'code' => 'department_customer_pricing_disabled',
'department' => $department,
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(int $departmentId, int $userId): array
{
global $db;
$sql = "
SELECT
c.`id` AS `category_id`,
c.`name` AS `category_name`,
c.`description` AS `category_description`,
p.*,
pdp.`price` AS `department_price`
FROM `department_categories` dc
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id`
AND pdp.`product_id` = p.`id`
WHERE dc.`department_id` = " . (int)$departmentId . "
AND dc.`deleted_at` IS NULL
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
$result = $db->query($sql);
$rows = $result ? $db->fetch_all($result) : [];
$customer = (new users_o())->getUserById($userId);
$categories = [];
$seen = [];
foreach ($rows as $row) {
$productId = (int)$row['id'];
if (isset($seen[$productId])) {
continue;
}
$seen[$productId] = true;
$categoryId = (int)$row['category_id'];
if (!isset($categories[$categoryId])) {
$categories[$categoryId] = [
'id' => $categoryId,
'name' => (string)$row['category_name'],
'description' => (string)($row['category_description'] ?? ''),
'products' => [],
];
}
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
if ($departmentPrice !== null) {
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
}
$categories[$categoryId]['products'][] = [
'id' => $productId,
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'category' => $categoryId,
'apply_category_discount' => (bool)$row['apply_category_discount'],
'base_price' => (int)$row['price'],
'department_price' => $departmentPrice,
'effective_price' => $effectivePrice,
'missing_department_price' => $departmentPrice === null,
];
}
return array_values($categories);
}
/**
* @param array<int, mixed> $overrides
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
*/
private function normalizeOverrides(int $departmentId, array $overrides): array
{
$normalized = [];
foreach ($overrides as $override) {
if (!is_array($override)) {
throw new limited_backoffice_exception('Invalid override payload.', 400);
}
$isCategory = (bool)($override['is_category'] ?? false);
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
if ($objectId === null || $objectId === '') {
throw new limited_backoffice_exception('Override object is required.', 400);
}
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
if ($percentage === false || $percentage < 0 || $percentage > 100) {
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
}
$fixedPrice = null;
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
if ($fixedPrice === false || $fixedPrice < 0) {
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
}
}
if ($isCategory) {
$fixedPrice = null;
$objectId = (string)$objectId;
if ($objectId !== 'global') {
$this->assertDepartmentCategory($departmentId, $objectId);
}
} else {
$objectId = (int)$objectId;
$this->assertDepartmentProduct($departmentId, $objectId);
}
if ($percentage <= 0 && $fixedPrice === null) {
continue;
}
$key = $this->overrideKey($isCategory, $objectId);
$normalized[$key] = [
'is_category' => $isCategory,
'product_or_category_id' => $objectId,
'percentage' => (int)$percentage,
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
];
}
return array_values($normalized);
}
/**
* @param array{id:int,customer_number:int,display_name:string} $customer
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
*/
private function recordVersion(array $customer, int $departmentId, array $override): void
{
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$customer['id'],
(int)$customer['customer_number'],
(bool)$override['is_category'],
(string)$override['product_or_category_id'],
(int)$override['percentage'],
date('Y-m-d H:i:s'),
'live.department_discount_override.route',
1.0,
false,
[
'route' => 'department_customer_pricing',
'department_id' => $departmentId,
],
$override['fixed_price'],
$departmentId
);
} catch (\Throwable) {
}
}
private function overrideKey(bool $isCategory, int|string $objectId): string
{
return ((int)$isCategory) . ':' . (string)$objectId;
}
private function assertDepartmentProduct(int $departmentId, int $productId): void
{
global $db;
$result = $db->query(
'SELECT p.`id`
FROM `department_categories` dc
INNER JOIN `products` p ON p.`category` = dc.`category_id`
WHERE dc.`department_id` = ' . (int)$departmentId . '
AND dc.`deleted_at` IS NULL
AND p.`id` = ' . (int)$productId . '
LIMIT 1'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Product is not available for this department.', 400);
}
}
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
{
global $db;
$categoryId = $db->escape_string($categoryId);
$result = $db->query(
"SELECT `id`
FROM `department_categories`
WHERE `department_id` = " . (int)$departmentId . "
AND `deleted_at` IS NULL
AND `category_id` = '{$categoryId}'
LIMIT 1"
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Category is not available for this department.', 400);
}
}
}
@@ -0,0 +1,252 @@
<?php
namespace classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
use Exception;
class department_wash_count_service
{
/**
* @throws Exception
*/
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT deduped.department_id,
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(*) AS wash_count
FROM (
SELECT dedupe_key,
department_id,
MIN(counted_at) AS counted_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
) deduped
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
ORDER BY deduped.department_id ASC, hour_bucket ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
}
/**
* @param array<int|string> $department_ids
* @return array{quantity:int,products:int,earnings:int,washes:int}
* @throws Exception
*/
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [
'quantity' => 0,
'products' => 0,
'earnings' => 0,
'washes' => 0,
];
}
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
COALESCE(SUM(oi.quantity), 0) AS products,
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL";
$result = $db->query($sql);
$row = is_object($result) ? $result->fetch_assoc() : null;
return [
'quantity' => (int)($row['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
'washes' => $this->countRows($date_start, $date_end, $normalized_department_ids),
];
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{id:int,department_id:int,created_at:string}>
* @throws Exception
*/
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
department_id,
MIN(counted_at) AS created_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
ORDER BY created_at ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'department_id' => (int)($row['department_id'] ?? 0),
'created_at' => (string)($row['created_at'] ?? ''),
];
}
return $rows;
}
/**
* @param array<int> $department_ids
* @throws Exception
*/
private function countRows(string $date_start, string $date_end, array $department_ids): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
{
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
o.id AS entity_id,
o.department_id,
o.created_at AS counted_at,
0 AS source_priority
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND p.is_wash = 1
UNION ALL
SELECT CASE
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
ELSE CONCAT('selfserve:', s.id)
END AS dedupe_key,
CASE
WHEN linked_o.id IS NOT NULL THEN linked_o.id
ELSE s.id
END AS entity_id,
COALESCE(linked_o.department_id, s.department_id) AS department_id,
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
1 AS source_priority
FROM selfserve_wash_sessions s
LEFT JOIN orders linked_o
ON linked_o.id = s.order_id
AND linked_o.deleted_at IS NULL
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
AND s.deleted_at IS NULL
AND s.completed_at IS NOT NULL
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
}
/**
* @param array<int|string> $ids
* @return array<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$value = (int)$id;
if ($value > 0) {
$normalized[$value] = $value;
}
}
return array_values($normalized);
}
/**
* @throws Exception
*/
private function validateDateRange(string $date_start, string $date_end): void
{
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
}
}
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email, string $email,
int $phone, int $phone,
?int $mobile_phone = null, ?int $mobile_phone = null,
object|array|null $company_information = null object|array|null $company_information = null,
?string $ean = null
): object ): object
{ {
$payload = [ $payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
]; ];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information)); $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); 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 private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{ {
if ($company_information === null) { 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) 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->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic; $this->economic = $economic;
} }
@@ -388,7 +389,16 @@ class economic_v2_distribution_service
continue; 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); $discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) { if ($discount_percentage <= 0) {
continue; continue;
@@ -1519,7 +1529,13 @@ class economic_v2_distribution_service
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity; $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); $discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) { if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100)); $line_price *= (1 - ($discount_percentage / 100));
@@ -1529,15 +1545,22 @@ class economic_v2_distribution_service
return $total; 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)) { if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
return $this->discount_resolution_cache[$cache_key]; return $this->discount_resolution_cache[$cache_key];
} }
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp); $scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) { ? (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; return $this->discount_resolution_cache[$cache_key] = $direct;
} }
@@ -1545,13 +1568,20 @@ class economic_v2_distribution_service
if ($product !== null) { if ($product !== null) {
$category = (string)$product->category->value(); $category = (string)$product->category->value();
if ($category !== '') { 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) { if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount; 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; 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 ( "CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL DEFAULT NULL,
user_id INT NOT NULL, user_id INT NOT NULL,
customer_number INT NOT NULL, customer_number INT NOT NULL,
is_category TINYINT(1) NOT NULL, is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL, object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL, discount INT NOT NULL,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL, effective_from DATETIME NOT NULL,
effective_to DATETIME NULL, effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live', source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -74,6 +76,7 @@ class economic_v2_schema_bootstrap
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_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_user (user_id),
INDEX idx_discount_override_versions_source (source) INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
$db->query($sql); $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; self::$initialized = true;
} }
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0; return ((int)($row['c'] ?? 0)) > 0;
} }
} }
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
string $source = 'live.discount_override', string $source = 'live.discount_override',
float $confidence = 1.0, float $confidence = 1.0,
bool $inferred = false, bool $inferred = false,
array $metadata = [] array $metadata = [],
?int $fixed_price = null,
?int $department_id = null
): array { ): array {
$identity = [ $identity = [
'user_id' => $user_id, 'user_id' => $user_id,
@@ -143,8 +145,11 @@ class economic_v2_versioning_service
'is_category' => (int)$is_category, 'is_category' => (int)$is_category,
'object_id' => (string)$object_id, '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( return $this->closeActiveVersion(
'customer_discount_override_versions', 'customer_discount_override_versions',
$identity, $identity,
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
$identity, $identity,
[ [
'discount' => (int)$discount, 'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
], ],
$this->normalizeDatetime($effective_from), $this->normalizeDatetime($effective_from),
$source, $source,
@@ -238,15 +244,21 @@ class economic_v2_versioning_service
int $customer_number, int $customer_number,
bool $is_category, bool $is_category,
int|string $object_id, int|string $object_id,
string $timestamp string $timestamp,
?int $department_id = null
): ?array { ): ?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( $rows = $this->resolveActiveVersions(
'customer_discount_override_versions', 'customer_discount_override_versions',
[ $identity,
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
],
$timestamp, $timestamp,
'effective_from DESC, id DESC', 'effective_from DESC, id DESC',
1 1
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
} }
// Discount overrides current state. // Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at'); $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' . $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' : ''); ($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll( $discount_rows = $this->fetchAll(
"SELECT $discount_cols "SELECT $discount_cols
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override', 'backfill.current_discount_override',
$confidence, $confidence,
true, 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'); $this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
} }
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
$bucket[$action]++; $bucket[$action]++;
} }
} }
@@ -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_FIELDS = ['customer', 'reference', 'po', 'notes'];
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price']; private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
private const WASH_CERTIFICATE_PRODUCT_ID = 41; private const WASH_CERTIFICATE_PRODUCT_ID = 41;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
private array $economicCustomerDiscountCache = []; private array $economicCustomerDiscountCache = [];
private array $userDisplayNameCache = []; private array $userDisplayNameCache = [];
private array $orderItemsPreviewCache = []; private array $orderItemsPreviewCache = [];
@@ -30,6 +31,8 @@ class invoice_period_flag_service
public function __construct() public function __construct()
{ {
invoice_period_flag_schema_bootstrap::ensureTables(); 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 public function createManualFlag(array $payload, int $userId): array
@@ -711,8 +714,21 @@ class invoice_period_flag_service
p.max_quantity_per_order, p.max_quantity_per_order,
c.name AS category_name, c.name AS category_name,
pdp.price AS department_price, pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage, CASE
category_discount.percentage AS category_discount_percentage 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 FROM orders o
LEFT JOIN ( LEFT JOIN (
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
@@ -726,7 +742,7 @@ class invoice_period_flag_service
LEFT JOIN categories c ON c.id = p.category 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 product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
LEFT JOIN ( 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 FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0 WHERE po.is_category = 0
@@ -743,6 +759,32 @@ class invoice_period_flag_service
) category_discount ) category_discount
ON category_discount.customer_number = o.customer_id ON category_discount.customer_number = o.customer_id
AND category_discount.product_or_category_id = p.category 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}' WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
AND o.deleted_at IS NULL AND o.deleted_at IS NULL
ORDER BY o.customer_id, o.id, oi.id"; ORDER BY o.customer_id, o.id, oi.id";
@@ -898,7 +940,7 @@ class invoice_period_flag_service
} }
$restrictedProducts = [ $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']], 'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']], 'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
]; ];
@@ -1924,10 +1966,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int private function calculateExpectedPrice(array $row): int
{ {
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row); $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 $base = $row['department_price'] !== null
? (int)$row['department_price'] ? (int)$row['department_price']
: ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0)); : (int)($row['product_base_price'] ?? 0);
$discount = $customMissingPrice ? 0 : $this->discountBreakdown($row)['applied_discount_percentage']; $discount = $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100))); return (int)round($base * (1 - ($discount / 100)));
} }
@@ -1945,6 +1996,7 @@ class invoice_period_flag_service
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_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, 'department_price' => $departmentPrice,
'effective_base_price' => $base, 'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'], 'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'], 'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'], 'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1964,15 +2016,25 @@ class invoice_period_flag_service
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1; $categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0; $categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0; $economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [ return [
'product_discount_percentage' => $productDiscount, 'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount, 'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount, '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 private function economicCustomerDiscountPercentage(array $row): int
{ {
$customerNumber = (int)($row['customer_number'] ?? 0); $customerNumber = (int)($row['customer_number'] ?? 0);
@@ -2036,6 +2098,11 @@ class invoice_period_flag_service
private function rowMatchesProductTerms(array $row, array $terms): bool 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( $haystack = strtolower(trim(
(string)($row['product_name'] ?? '') . ' ' . (string)($row['product_name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '') (string)($row['category_name'] ?? '')
@@ -3,6 +3,7 @@
namespace classes; namespace classes;
use mysqli; use mysqli;
use objects\logs_o;
use objects\products_o; use objects\products_o;
use objects\users_o; use objects\users_o;
@@ -10,15 +11,48 @@ class limited_backoffice_service
{ {
public const PERMISSION_ACCESS = 'limited_backoffice_access'; public const PERMISSION_ACCESS = 'limited_backoffice_access';
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage'; public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
public const PERMISSION_VIEW_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_view';
public const PERMISSION_MANAGE_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_manage';
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage'; public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
private const MANAGED_EMPLOYEE_CUSTOMER_NUMBER = 0;
/**
* Permissions required for managed employees to sign in, appear in the employee login picker,
* and open the department admin shell used by their scoped role permissions.
*
* @var array<int, string>
*/
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
'admin',
'user',
'permissions_list_own',
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
];
/**
* Permissions that are always granted to managed employees when present in a role preset,
* regardless of whether the creating manager holds those permissions themselves.
*
* @var array<int, string>
*/
private const ROLE_UNCONDITIONAL_PERMISSIONS = [
'list_departments',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
];
/** /**
* @var array<string, array{label:string,description:string,permissions:array<int,string>}> * @var array<string, array{label:string,description:string,permissions:array<int,string>}>
*/ */
private const ROLE_PRESETS = [ private const ROLE_PRESETS = [
'viewer' => [ 'viewer' => [
'label' => 'Viewer', 'label' => 'Deactivated',
'description' => 'Can sign in and view assigned department data.', 'description' => 'Keeps the employee registered without order, booking, or management permissions.',
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
@@ -26,18 +60,58 @@ class limited_backoffice_service
], ],
'cashier' => [ 'cashier' => [
'label' => 'Cashier', 'label' => 'Cashier',
'description' => 'Can work with orders and order lines for assigned departments.', 'description' => 'Can work with POS orders, products, customers, vehicles, attachments, payments, scanners, and bookings for assigned departments.',
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order',
'add_order', 'add_order',
'edit_order', 'edit_order',
'mark_order_as_completed',
'list_order_items', 'list_order_items',
'add_order_items', 'add_order_items',
'edit_order_items', 'edit_order_items',
'delete_order_items', 'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order', 'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'add_bookings',
'complete_bookings',
'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
], ],
], ],
'booking_coordinator' => [ 'booking_coordinator' => [
@@ -46,16 +120,23 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'list_bookings', 'list_bookings',
'list_own_bookings', 'list_own_bookings',
'edit_bookings', 'edit_bookings',
'add_booking', 'add_booking',
'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'department_timebookings_entries_get', 'department_timebookings_entries_get',
'department_timebookings_entries_post', 'department_timebookings_entries_post',
'department_timebookings_entries_put', 'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
], ],
], ],
'operations_lead' => [ 'operations_lead' => [
@@ -64,21 +145,53 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order',
'add_order', 'add_order',
'edit_order', 'edit_order',
'delete_order', 'delete_order',
'mark_order_as_completed',
'list_order_items', 'list_order_items',
'add_order_items', 'add_order_items',
'edit_order_items', 'edit_order_items',
'delete_order_items', 'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order', 'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings', 'list_bookings',
'list_own_bookings', 'list_own_bookings',
'edit_bookings', 'edit_bookings',
'add_booking', 'add_booking',
'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
], ],
@@ -89,25 +202,59 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order',
'add_order', 'add_order',
'edit_order', 'edit_order',
'delete_order', 'delete_order',
'mark_order_as_completed',
'list_order_items', 'list_order_items',
'add_order_items', 'add_order_items',
'edit_order_items', 'edit_order_items',
'delete_order_items', 'delete_order_items',
'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products',
'list_categories',
'list_department_categories',
'list_department_order_recommended',
'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number',
'list_customer_notes',
'add_customer_note',
'list_customer_attributes',
'search_vehicles',
'view_vehicle_status',
'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
'list_number_plate_scans',
'list_department_number_plate_scanners',
'charge_order', 'charge_order',
'get_payment_intent',
'confirm_payment_intent',
'modules_stripe_department_terminal_readers_list',
'modules_stripe_invoice_send',
'list_bookings', 'list_bookings',
'list_own_bookings', 'list_own_bookings',
'edit_bookings', 'edit_bookings',
'add_booking', 'add_booking',
'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
self::PERMISSION_ACCESS, self::PERMISSION_ACCESS,
self::PERMISSION_MANAGE_PRICES, self::PERMISSION_MANAGE_PRICES,
self::PERMISSION_VIEW_CUSTOMER_PRICING,
self::PERMISSION_MANAGE_CUSTOMER_PRICING,
self::PERMISSION_MANAGE_EMPLOYEES, self::PERMISSION_MANAGE_EMPLOYEES,
], ],
], ],
@@ -129,6 +276,10 @@ class limited_backoffice_service
'group' => 'orders', 'group' => 'orders',
'capability' => 'view_orders', 'capability' => 'view_orders',
], ],
'fetch_order' => [
'group' => 'orders',
'capability' => 'view_orders',
],
'add_order' => [ 'add_order' => [
'group' => 'orders', 'group' => 'orders',
'capability' => 'create_orders', 'capability' => 'create_orders',
@@ -141,6 +292,10 @@ class limited_backoffice_service
'group' => 'orders', 'group' => 'orders',
'capability' => 'delete_orders', 'capability' => 'delete_orders',
], ],
'mark_order_as_completed' => [
'group' => 'orders',
'capability' => 'complete_orders',
],
'list_order_items' => [ 'list_order_items' => [
'group' => 'orders', 'group' => 'orders',
'capability' => 'view_order_items', 'capability' => 'view_order_items',
@@ -157,10 +312,110 @@ class limited_backoffice_service
'group' => 'orders', 'group' => 'orders',
'capability' => 'remove_order_lines', 'capability' => 'remove_order_lines',
], ],
'list_order_attachments' => [
'group' => 'attachments',
'capability' => 'view_order_attachments',
],
'add_order_attachments' => [
'group' => 'attachments',
'capability' => 'add_order_attachments',
],
'download_order_attachments' => [
'group' => 'attachments',
'capability' => 'download_order_attachments',
],
'list_products' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_categories' => [
'group' => 'products',
'capability' => 'view_product_catalog',
],
'list_department_order_recommended' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'vehicle_product_suggestions' => [
'group' => 'products',
'capability' => 'view_product_recommendations',
],
'search_customers' => [
'group' => 'customers',
'capability' => 'search_customers',
],
'get_user_from_customer_number' => [
'group' => 'customers',
'capability' => 'view_customer_details',
],
'list_customer_notes' => [
'group' => 'customers',
'capability' => 'view_customer_notes',
],
'add_customer_note' => [
'group' => 'customers',
'capability' => 'add_customer_notes',
],
'list_customer_attributes' => [
'group' => 'customers',
'capability' => 'view_customer_flags',
],
'search_vehicles' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'view_vehicle_status' => [
'group' => 'vehicles',
'capability' => 'search_vehicles',
],
'list_unknown_customer_vehicles' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'list_vehicle_customer_suggestions' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_matches',
],
'department_license_plate_lookup' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'department_vehicle_order_last_five' => [
'group' => 'vehicles',
'capability' => 'view_vehicle_history',
],
'list_number_plate_scans' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'list_department_number_plate_scanners' => [
'group' => 'scanner',
'capability' => 'view_plate_scans',
],
'charge_order' => [ 'charge_order' => [
'group' => 'orders', 'group' => 'orders',
'capability' => 'charge_orders', 'capability' => 'charge_orders',
], ],
'get_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'confirm_payment_intent' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_department_terminal_readers_list' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'modules_stripe_invoice_send' => [
'group' => 'orders',
'capability' => 'charge_orders',
],
'list_bookings' => [ 'list_bookings' => [
'group' => 'bookings', 'group' => 'bookings',
'capability' => 'view_department_bookings', 'capability' => 'view_department_bookings',
@@ -177,6 +432,10 @@ class limited_backoffice_service
'group' => 'bookings', 'group' => 'bookings',
'capability' => 'create_bookings', 'capability' => 'create_bookings',
], ],
'add_bookings' => [
'group' => 'bookings',
'capability' => 'create_bookings',
],
'complete_bookings' => [ 'complete_bookings' => [
'group' => 'bookings', 'group' => 'bookings',
'capability' => 'mark_bookings_complete', 'capability' => 'mark_bookings_complete',
@@ -197,6 +456,22 @@ class limited_backoffice_service
'group' => 'time_bookings', 'group' => 'time_bookings',
'capability' => 'edit_time_booking_entries', 'capability' => 'edit_time_booking_entries',
], ],
'list_departments' => [
'group' => 'departments',
'capability' => 'view_departments',
],
'list_department_daily_reports' => [
'group' => 'departments',
'capability' => 'view_daily_reports',
],
'list_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'list_own_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'statistics_orders_new' => [ 'statistics_orders_new' => [
'group' => 'reports', 'group' => 'reports',
'capability' => 'view_order_statistics', 'capability' => 'view_order_statistics',
@@ -213,6 +488,14 @@ class limited_backoffice_service
'group' => 'limited_backoffice', 'group' => 'limited_backoffice',
'capability' => 'manage_department_prices', 'capability' => 'manage_department_prices',
], ],
self::PERMISSION_VIEW_CUSTOMER_PRICING => [
'group' => 'limited_backoffice',
'capability' => 'view_customer_pricing',
],
self::PERMISSION_MANAGE_CUSTOMER_PRICING => [
'group' => 'limited_backoffice',
'capability' => 'manage_customer_pricing',
],
self::PERMISSION_MANAGE_EMPLOYEES => [ self::PERMISSION_MANAGE_EMPLOYEES => [
'group' => 'limited_backoffice', 'group' => 'limited_backoffice',
'capability' => 'manage_employee_access', 'capability' => 'manage_employee_access',
@@ -224,9 +507,16 @@ class limited_backoffice_service
*/ */
private const ROLE_PERMISSION_GROUP_ORDER = [ private const ROLE_PERMISSION_GROUP_ORDER = [
'account', 'account',
'departments',
'orders', 'orders',
'products',
'customers',
'vehicles',
'attachments',
'scanner',
'bookings', 'bookings',
'time_bookings', 'time_bookings',
'notifications',
'reports', 'reports',
'limited_backoffice', 'limited_backoffice',
]; ];
@@ -255,20 +545,41 @@ class limited_backoffice_service
/** /**
* @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,string>}>}> * @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,string>}>}>
*/ */
public function rolePresets(): array public function rolePresets(?users_o $manager = null): array
{ {
$roles = []; $roles = [];
foreach (self::ROLE_PRESETS as $key => $preset) { foreach (self::ROLE_PRESETS as $key => $preset) {
$permissions = $manager === null
? $preset['permissions']
: $this->effectiveRolePermissionsForManager($manager, $key, false);
$roles[] = [ $roles[] = [
'key' => $key, 'key' => $key,
'label' => $preset['label'], 'label' => $preset['label'],
'description' => $preset['description'], 'description' => $preset['description'],
'permission_groups' => $this->rolePermissionGroups($preset['permissions']), 'permission_groups' => $this->rolePermissionGroups($permissions),
]; ];
} }
return $roles; return $roles;
} }
/**
* @return array<int, array{key:string,label:string,description:string,permissions:array<int,string>}>
*/
public function rolePermissionTemplates(): array
{
$templates = [];
foreach (self::ROLE_PRESETS as $key => $preset) {
$templates[] = [
'key' => $key,
'label' => $preset['label'],
'description' => $preset['description'],
'permissions' => array_values($preset['permissions']),
];
}
return $templates;
}
/** /**
* @param array<int, string> $permissions * @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}> * @return array<int, array{key:string,capabilities:array<int,string>}>
@@ -316,6 +627,15 @@ class limited_backoffice_service
return []; return [];
} }
if ($user->hasPermission('superuser')) {
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
}
global $db; global $db;
$statement = $this->mysqli()->prepare( $statement = $this->mysqli()->prepare(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?' 'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?'
@@ -458,26 +778,26 @@ class limited_backoffice_service
$mysqli->begin_transaction(); $mysqli->begin_transaction();
try { try {
$priceUpdateAssignments = ['`price` = VALUES(`price`)']; $deleteStatement = $mysqli->prepare(
if ($this->tableHasColumn('product_department_prices', 'updated_at')) { 'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
$priceUpdateAssignments[] = '`updated_at` = CURRENT_TIMESTAMP';
}
$statement = $mysqli->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE ' . implode(', ', $priceUpdateAssignments)
); );
if ($statement === false) { $insertStatement = $mysqli->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`) VALUES (?, ?, ?)'
);
if ($deleteStatement === false || $insertStatement === false) {
throw new \RuntimeException('Unable to prepare department price update.'); throw new \RuntimeException('Unable to prepare department price update.');
} }
foreach ($normalizedPrices as $productId => $price) { foreach ($normalizedPrices as $productId => $price) {
$statement->bind_param('iii', $departmentId, $productId, $price); $deleteStatement->bind_param('ii', $departmentId, $productId);
$statement->execute(); $deleteStatement->execute();
$insertStatement->bind_param('iii', $departmentId, $productId, $price);
$insertStatement->execute();
} }
$statement->close(); $deleteStatement->close();
$insertStatement->close();
$mysqli->commit(); $mysqli->commit();
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
$mysqli->rollback(); $mysqli->rollback();
@@ -487,6 +807,34 @@ class limited_backoffice_service
return $this->getDepartmentPrices($user, $departmentId); return $this->getDepartmentPrices($user, $departmentId);
} }
/**
* @return array<string, mixed>
*/
public function getDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId): array
{
$this->assertDepartmentAccess($user, $departmentId);
return (new department_customer_pricing_service())->getPricing($departmentId, $customerUserId);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updateDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId, array $payload): array
{
$this->assertDepartmentAccess($user, $departmentId);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
return (new department_customer_pricing_service())->updatePricing($departmentId, $customerUserId, [
...$payload,
'department_id' => $departmentId,
'user_id' => $customerUserId,
]);
}
/** /**
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -557,7 +905,7 @@ class limited_backoffice_service
try { try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds); $groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$customerNumber = $this->generateEmployeeCustomerNumber(); $customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
$passwordHash = password_hash($password, PASSWORD_DEFAULT); $passwordHash = password_hash($password, PASSWORD_DEFAULT);
$statement = $mysqli->prepare( $statement = $mysqli->prepare(
@@ -582,15 +930,7 @@ class limited_backoffice_service
$employeeId = (int)$mysqli->insert_id; $employeeId = (int)$mysqli->insert_id;
$statement->close(); $statement->close();
$groupName = 'Limited employee #' . $employeeId; $this->renameManagedGroup($groupId, $employeeId);
$groupDescription = 'Managed by limited backoffice.';
$statement = $mysqli->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) { if (!is_string($departmentJson)) {
@@ -624,6 +964,72 @@ class limited_backoffice_service
return $this->formatEmployee($employee, $departmentIds, true); return $this->formatEmployee($employee, $departmentIds, true);
} }
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function migrateEmployee(users_o $manager, int $employeeId, array $payload): array
{
$this->rejectRawPermissionPayload($payload);
$this->assertNotSelfEdit($manager, $employeeId);
if ($this->loadManagedEmployee($employeeId) !== null) {
throw new limited_backoffice_exception('User is already a limited backoffice employee.', 409);
}
$target = $this->loadMigratableUser($employeeId);
if ($target === null) {
throw new limited_backoffice_exception('User not found.', 404);
}
$this->assertMigrationTargetIsSafe($target);
$departmentIds = $this->normalizeDepartmentIds($payload['department_ids'] ?? null);
$this->assertDepartmentSubset($manager, $departmentIds);
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
$departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($departmentJson)) {
throw new limited_backoffice_exception('Unable to encode department metadata.', 500);
}
$mysqli = $this->mysqli();
$mysqli->begin_transaction();
try {
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$this->renameManagedGroup($groupId, $employeeId);
$this->updateUserFields($employeeId, [
'group_id' => $groupId,
]);
$managerId = (int)$manager->id;
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`, `updated_by_user_id`)
VALUES (?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare migrated employee metadata insert.');
}
$statement->bind_param('iissii', $employeeId, $groupId, $roleKey, $departmentJson, $managerId, $managerId);
$statement->execute();
$statement->close();
$this->clearUserSessionCache($employeeId);
$mysqli->commit();
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to migrate employee.', 500);
}
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Unable to load migrated employee.', 500);
}
return $this->formatEmployee($employee, $departmentIds, true);
}
/** /**
* @param array<string, mixed> $payload * @param array<string, mixed> $payload
* @return array<string, mixed> * @return array<string, mixed>
@@ -677,7 +1083,7 @@ class limited_backoffice_service
try { try {
if ($active) { if ($active) {
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($roleKey, $newDepartmentIds)); $this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
} }
$userUpdates = []; $userUpdates = [];
@@ -750,6 +1156,47 @@ class limited_backoffice_service
return $this->updateEmployee($manager, $employeeId, ['active' => false]); return $this->updateEmployee($manager, $employeeId, ['active' => false]);
} }
/**
* @return array{employee_id:int,login_path:string}
*/
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
{
$this->assertNotSelfEdit($manager, $employeeId);
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Managed employee not found.', 404);
}
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
$this->assertDepartmentSubset($manager, $departmentIds);
$this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
}
$token = (new authentication())->create_employee_token($employeeId);
try {
(new logs_o())->add(
'auth',
'global',
1,
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
'Created limited backoffice login link for employee: ' . $employeeId
);
} catch (\Throwable) {
// Audit logging should not block login-link generation.
}
return [
'employee_id' => $employeeId,
'login_path' => '/login/qr?token=' . $token,
];
}
private function mysqli(): mysqli private function mysqli(): mysqli
{ {
global $db; global $db;
@@ -852,8 +1299,14 @@ class limited_backoffice_service
INNER JOIN `categories` c ON c.`id` = dc.`category_id` INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id` INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id` ON pdp.`id` = (
AND pdp.`product_id` = p.`id` SELECT pdp_latest.`id`
FROM `product_department_prices` pdp_latest
WHERE pdp_latest.`department_id` = dc.`department_id`
AND pdp_latest.`product_id` = p.`id`
ORDER BY pdp_latest.`id` DESC
LIMIT 1
)
WHERE ' . implode(' AND ', $where) . ' WHERE ' . implode(' AND ', $where) . '
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC' ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC'
); );
@@ -869,9 +1322,15 @@ class limited_backoffice_service
$categories = []; $categories = [];
$missing = []; $missing = [];
$requiredProductIds = []; $requiredProductIds = [];
$seenProductIds = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$categoryId = (int)$row['category_id']; $categoryId = (int)$row['category_id'];
$productId = (int)$row['product_id']; $productId = (int)$row['product_id'];
if (isset($seenProductIds[$productId])) {
continue;
}
$seenProductIds[$productId] = true;
$requiredProductIds[] = $productId; $requiredProductIds[] = $productId;
if (!isset($categories[$categoryId])) { if (!isset($categories[$categoryId])) {
@@ -911,7 +1370,7 @@ class limited_backoffice_service
return [ return [
'categories' => array_values($categories), 'categories' => array_values($categories),
'missing_products' => $missing, 'missing_products' => $missing,
'required_product_ids' => array_values(array_unique($requiredProductIds)), 'required_product_ids' => array_values($requiredProductIds),
]; ];
} }
@@ -1220,18 +1679,57 @@ class limited_backoffice_service
$groupId = (int)$this->mysqli()->insert_id; $groupId = (int)$this->mysqli()->insert_id;
$statement->close(); $statement->close();
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($roleKey, $departmentIds)); $this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $departmentIds));
return $groupId; return $groupId;
} }
private function renameManagedGroup(int $groupId, int $employeeId): void
{
$groupName = 'Limited employee #' . $employeeId;
$groupDescription = 'Managed by limited backoffice.';
$statement = $this->mysqli()->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare group update.');
}
$statement->bind_param('ssi', $groupName, $groupDescription, $groupId);
$statement->execute();
$statement->close();
}
/**
* @return array<int, string>
*/
private function effectiveRolePermissionsForManager(users_o $manager, string $roleKey, bool $includePublicVisibility): array
{
$permissions = $includePublicVisibility ? self::MANAGED_EMPLOYEE_BASE_PERMISSIONS : ['user'];
foreach (self::ROLE_PRESETS[$roleKey]['permissions'] ?? [] as $permission) {
if (in_array($permission, self::MANAGED_EMPLOYEE_BASE_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if (in_array($permission, self::ROLE_UNCONDITIONAL_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if ($manager->hasPermission($permission)) {
$permissions[] = $permission;
}
}
return array_values(array_unique($permissions));
}
/** /**
* @param array<int, int> $departmentIds * @param array<int, int> $departmentIds
* @return array<int, string> * @return array<int, string>
*/ */
private function permissionsForRoleAndDepartments(string $roleKey, array $departmentIds): array private function permissionsForRoleAndDepartments(users_o $manager, string $roleKey, array $departmentIds): array
{ {
$permissions = self::ROLE_PRESETS[$roleKey]['permissions'] ?? []; $permissions = $this->effectiveRolePermissionsForManager($manager, $roleKey, true);
foreach ($departmentIds as $departmentId) { foreach ($departmentIds as $departmentId) {
$permissions[] = 'department_access_' . $departmentId; $permissions[] = 'department_access_' . $departmentId;
} }
@@ -1273,29 +1771,6 @@ class limited_backoffice_service
$insert->close(); $insert->close();
} }
private function generateEmployeeCustomerNumber(): int
{
$mysqli = $this->mysqli();
for ($attempt = 0; $attempt < 20; $attempt++) {
$customerNumber = random_int(900000000, 999999999);
$statement = $mysqli->prepare('SELECT `id` FROM `users` WHERE `customer_number` = ? LIMIT 1');
if ($statement === false) {
throw new \RuntimeException('Unable to prepare customer number check.');
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$exists = $result->num_rows > 0;
$statement->close();
if (!$exists) {
return $customerNumber;
}
}
throw new \RuntimeException('Unable to generate employee customer number.');
}
/** /**
* @return array<string, mixed>|null * @return array<string, mixed>|null
*/ */
@@ -1333,6 +1808,42 @@ class limited_backoffice_service
return is_array($row) ? $row : null; return is_array($row) ? $row : null;
} }
/**
* @return array<string, mixed>|null
*/
private function loadMigratableUser(int $employeeId): ?array
{
global $db;
$userDeletedAtSelect = $this->tableHasColumn('users', 'deleted_at')
? 'u.`deleted_at` AS `user_deleted_at`'
: 'NULL AS `user_deleted_at`';
$statement = $this->mysqli()->prepare(
'SELECT
u.`id`,
u.`customer_number`,
u.`display_name`,
u.`email`,
u.`phone_country_code`,
u.`phone`,
u.`group_id`,
' . $userDeletedAtSelect . '
FROM `users` u
WHERE u.`id` = ?
LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to load user.', 500);
}
$statement->bind_param('i', $employeeId);
$statement->execute();
$result = $statement->get_result();
$row = $db->fetch_assoc($result);
$statement->close();
return is_array($row) ? $row : null;
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
*/ */
@@ -1350,6 +1861,7 @@ class limited_backoffice_service
{ {
return [ return [
'id' => (int)$row['user_id'], 'id' => (int)$row['user_id'],
'user_id' => (int)$row['user_id'],
'customer_number' => (int)$row['customer_number'], 'customer_number' => (int)$row['customer_number'],
'display_name' => (string)($row['display_name'] ?? ''), 'display_name' => (string)($row['display_name'] ?? ''),
'email' => $row['email'] === null ? null : (string)$row['email'], 'email' => $row['email'] === null ? null : (string)$row['email'],
@@ -1426,6 +1938,25 @@ class limited_backoffice_service
} }
} }
/**
* @param array<string, mixed> $target
*/
private function assertMigrationTargetIsSafe(array $target): void
{
if ((int)($target['customer_number'] ?? -1) !== self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER) {
throw new limited_backoffice_exception('Only employee accounts with customer number 0 can be migrated.', 400);
}
$groupId = (int)($target['group_id'] ?? 0);
if ($groupId === 1 || $this->groupHasPermission($groupId, 'superuser')) {
throw new limited_backoffice_exception('Cannot migrate superuser accounts.', 403);
}
if (($target['user_deleted_at'] ?? null) !== null) {
throw new limited_backoffice_exception('Cannot migrate inactive users.', 409);
}
}
private function groupHasPermission(int $groupId, string $permission): bool private function groupHasPermission(int $groupId, string $permission): bool
{ {
if ($groupId <= 0) { if ($groupId <= 0) {
@@ -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_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status), INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number), 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", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers ( "CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
@@ -219,6 +221,16 @@ class selfserve_schema_bootstrap
'wash_started_at', 'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_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; self::$initialized = true;
} }
@@ -252,6 +264,35 @@ class selfserve_schema_bootstrap
$db->query($alterSql); $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 * @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 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 QUERY_PREFIX = self::PREFIX . 'query:';
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables'; public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request'; public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:'; 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); 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 public static function clearAll(): void
{ {
self::clearPattern(self::PREFIX . '*'); self::clearPattern(self::PREFIX . '*');
@@ -68,11 +49,6 @@ class system_search_cache
self::clearPattern(self::QUERY_PREFIX . '*'); self::clearPattern(self::QUERY_PREFIX . '*');
} }
public static function clearIntentCaches(): void
{
self::clearPattern(self::INTENT_PREFIX . '*');
}
public static function markDirtyTable(string $table): void public static function markDirtyTable(string $table): void
{ {
$table = trim($table, " `\t\n\r\0\x0B"); $table = trim($table, " `\t\n\r\0\x0B");
@@ -499,6 +499,7 @@ class system_search_document_index
*/ */
private function buildCustomerDiscountDocuments(): array private function buildCustomerDiscountDocuments(): array
{ {
price_overrides_schema_bootstrap::ensureColumns();
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
$selectFields = [ $selectFields = [
'po.id AS entity_id', 'po.id AS entity_id',
@@ -506,6 +507,7 @@ class system_search_document_index
'po.is_category', 'po.is_category',
'po.product_or_category_id', 'po.product_or_category_id',
'po.percentage', 'po.percentage',
'po.fixed_price',
'u.customer_number', 'u.customer_number',
'u.display_name', 'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'), ...$this->joinTemporalSelectFields('price_overrides', 'po'),
@@ -554,6 +556,7 @@ class system_search_document_index
$row['search_text'] ?? null, $row['search_text'] ?? null,
$row['product_or_category_id'] ?? null, $row['product_or_category_id'] ?? null,
$row['percentage'] ?? null, $row['percentage'] ?? null,
$row['fixed_price'] ?? null,
$row['user_id'] ?? null, $row['user_id'] ?? null,
]), ]),
$this->toIntOrNull($row['customer_number'] ?? null), $this->toIntOrNull($row['customer_number'] ?? null),
@@ -564,6 +567,7 @@ class system_search_document_index
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
'product_or_category_id' => $row['product_or_category_id'] ?? null, 'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => $this->toIntOrNull($row['percentage'] ?? null), 'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null, 'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? 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; namespace classes;
use interfaces\system_search_intent_parser_i;
use Throwable; use Throwable;
class system_search_service 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 $defaultEntityFetchLimit = 200;
private int $defaultMaxResults = 50;
private int $maxExpandedTerms = 24; private int $maxExpandedTerms = 24;
private int $maxTermLength = 64; private int $maxTermLength = 64;
private int $recencyScoreTolerance = 12; private int $recencyScoreTolerance = 12;
private int $minimumEffectiveScore = 45;
private int $minimumExplicitTypeScore = 20;
private int $associationSeedScoreThreshold = 80;
private array $rankingBoostByType = [ private array $rankingBoostByType = [
'invoices' => 35, 'invoices' => 35,
'orders' => 35, 'orders' => 35,
@@ -35,9 +35,8 @@ class system_search_service
private array $tableColumnsCache = []; private array $tableColumnsCache = [];
private array $customerContextCache = []; 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 { try {
system_search_economic_customer_index::ensureTable(); system_search_economic_customer_index::ensureTable();
system_search_document_index::ensureTable(); system_search_document_index::ensureTable();
@@ -59,18 +58,13 @@ class system_search_service
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []); $permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []); $moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
$includeAssociations = (bool)($options['include_associations'] ?? true); $includeAssociations = (bool)($options['include_associations'] ?? true);
$debugIntent = (bool)($options['debug_intent'] ?? false);
$limit = (int)($options['limit'] ?? 50); $maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
$offset = (int)($options['offset'] ?? 0); if ($maxResults < 1) {
if ($limit < 1) { $maxResults = $this->defaultMaxResults;
$limit = 50;
} }
if ($limit > 200) { if ($maxResults > $this->defaultMaxResults) {
$limit = 200; $maxResults = $this->defaultMaxResults;
}
if ($offset < 0) {
$offset = 0;
} }
$allTypes = $this->allEntityTypes(); $allTypes = $this->allEntityTypes();
@@ -79,11 +73,12 @@ class system_search_service
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes)); $activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
} }
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes)); $activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
$terms = $this->buildExpandedTerms($this->tokenize($query));
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
$baseMeta = [ $baseMeta = [
'query' => $query, 'query' => $query,
'limit' => $limit, 'max_results' => $maxResults,
'offset' => $offset,
'allowed_types' => $activeTypes, 'allowed_types' => $activeTypes,
'cache' => ['hit' => false], 'cache' => ['hit' => false],
]; ];
@@ -94,7 +89,8 @@ class system_search_service
'grouped_results' => $this->groupResultsByType([]), 'grouped_results' => $this->groupResultsByType([]),
'meta' => [ 'meta' => [
...$baseMeta, ...$baseMeta,
'total' => 0, 'returned' => 0,
'truncated' => false,
], ],
]; ];
} }
@@ -104,16 +100,14 @@ class system_search_service
'include' => $includeTypes, 'include' => $includeTypes,
'exclude' => $excludeTypes, 'exclude' => $excludeTypes,
'active' => $activeTypes, 'active' => $activeTypes,
'limit' => $limit, 'max' => $maxResults,
'offset' => $offset,
'own' => $ownCustomerNumber, 'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes, 'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds, 'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations, 'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility), 'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)), 'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
'v' => 12, 'v' => 13,
], JSON_UNESCAPED_UNICODE)); ], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getQuery($queryCacheHash); $cached = system_search_cache::getQuery($queryCacheHash);
@@ -122,7 +116,6 @@ class system_search_service
return $cached; return $cached;
} }
$terms = $this->buildExpandedTerms($this->tokenize($query));
$entityBoost = []; $entityBoost = [];
$initialResults = $this->executeLexicalSearch( $initialResults = $this->executeLexicalSearch(
$activeTypes, $activeTypes,
@@ -135,76 +128,23 @@ class system_search_service
$moduleConfigVisibility, $moduleConfigVisibility,
$allowedDepartmentIds $allowedDepartmentIds
); );
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$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';
}
}
if ($includeAssociations) { if ($includeAssociations) {
$customerNumbers = []; $customerNumbers = [];
foreach ($initialResults as $result) { foreach ($initialResults as $result) {
if (!isset($result['customer_number'])) { $customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
if ($customerNumber === null || $customerNumber <= 0) {
continue; continue;
} }
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) { if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
continue; continue;
} }
$customerNumbers[] = (int)$result['customer_number']; $customerNumbers[] = $customerNumber;
} }
$customerNumbers = array_values(array_unique(array_filter($customerNumbers))); $customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
if (count($customerNumbers) > 15) { if (count($customerNumbers) > 5) {
$customerNumbers = array_slice($customerNumbers, 0, 15); $customerNumbers = array_slice($customerNumbers, 0, 5);
} }
if (!empty($customerNumbers)) { if (!empty($customerNumbers)) {
$associationTypes = array_values(array_intersect( $associationTypes = array_values(array_intersect(
@@ -232,18 +172,21 @@ class system_search_service
} }
$item['score'] = max((int)$item['score'], 35); $item['score'] = max((int)$item['score'], 35);
} }
unset($item);
$associated = $this->filterRelevantResults($associated, $includeTypes);
$initialResults = $this->mergeResults($initialResults, $associated); $initialResults = $this->mergeResults($initialResults, $associated);
} }
} }
} }
} }
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$preferRecency = $this->shouldPreferRecencySort($query, $terms); $preferRecency = $this->shouldPreferRecencySort($query, $terms);
usort($initialResults, function (array $a, array $b) use ($preferRecency): int { usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
$scoreA = (int)($a['score'] ?? 0); $scoreA = (int)($a['score'] ?? 0);
$scoreB = (int)($b['score'] ?? 0); $scoreB = (int)($b['score'] ?? 0);
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a); $effectiveScoreA = $this->effectiveResultScore($a);
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b); $effectiveScoreB = $this->effectiveResultScore($b);
$recencyA = $this->resultRecencyTimestamp($a); $recencyA = $this->resultRecencyTimestamp($a);
$recencyB = $this->resultRecencyTimestamp($b); $recencyB = $this->resultRecencyTimestamp($b);
$cancelledA = $this->isCancelledBookingResult($a); $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']); return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
}); });
$total = count($initialResults); $limited = array_slice($initialResults, 0, $maxResults);
$paged = array_slice($initialResults, $offset, $limit); $grouped = $this->groupResultsByType($limited);
$grouped = $this->groupResultsByType($paged);
$meta = [ $meta = [
...$baseMeta, ...$baseMeta,
'total' => $total, 'returned' => count($limited),
'truncated' => count($initialResults) > count($limited),
]; ];
if ($debugIntent) {
$meta['intent_parser'] = $intentMeta;
}
$payload = [ $payload = [
'results' => $paged, 'results' => $limited,
'grouped_results' => $grouped, 'grouped_results' => $grouped,
'meta' => $meta, 'meta' => $meta,
]; ];
@@ -294,13 +234,127 @@ class system_search_service
return $payload; 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) { if (empty($activeTypes) || !empty($includeTypes)) {
return true; 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, $allowedDepartmentIds,
$forcedCustomerNumbers $forcedCustomerNumbers
); );
if (empty($rows)) {
$rows = $this->searchEntity(
$entityType,
$terms,
$boost,
$ownOnly,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
} else { } else {
$rows = $this->searchEntity( $rows = $this->searchEntity(
$entityType, $entityType,
@@ -1086,6 +1126,7 @@ class system_search_service
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
{ {
price_overrides_schema_bootstrap::ensureColumns();
$customerFilter = ''; $customerFilter = '';
if (!empty($forcedCustomerNumbers)) { if (!empty($forcedCustomerNumbers)) {
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
@@ -1100,11 +1141,12 @@ class system_search_service
'po.is_category', 'po.is_category',
'po.product_or_category_id', 'po.product_or_category_id',
'po.percentage', 'po.percentage',
'po.fixed_price',
'u.customer_number', 'u.customer_number',
'u.display_name', 'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'), ...$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()) { if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; $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', 'search_text',
'product_or_category_id', 'product_or_category_id',
'percentage', 'percentage',
'fixed_price',
'user_id', 'user_id',
], $terms) + $entityBoost, ], $terms) + $entityBoost,
'payload' => $this->augmentPayloadWithTemporal([ 'payload' => $this->augmentPayloadWithTemporal([
@@ -1173,6 +1216,7 @@ class system_search_service
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
'product_or_category_id' => $row['product_or_category_id'] ?? null, 'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : 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_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null,
], $row), ], $row),
@@ -2643,6 +2687,11 @@ class system_search_service
return (int)($this->rankingPenaltyByType[$entityType] ?? 0); 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 private function boolishTrue(mixed $value): bool
{ {
if (is_bool($value)) { if (is_bool($value)) {
@@ -2702,7 +2751,7 @@ class system_search_service
return false; 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)) { if ($this->queryHasExplicitIdentifier($normalized)) {
return false; 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 private function queryHasExplicitIdentifier(string $normalizedQuery): bool
{ {
if ($normalizedQuery === '') { if ($normalizedQuery === '') {
@@ -2981,22 +2968,6 @@ class system_search_service
return system_search_registry::allEntityTypes(); 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 private function groupResultsByType(array $results): array
{ {
$grouped = []; $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); 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 public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
{ {
$usageBag = self::productBagForAutomation($usageItems); $usageBag = self::productBagForAutomation($usageItems);
@@ -600,18 +606,52 @@ class xlvask_automation_service
if ($action === self::ACTION_ATTACH) { if ($action === self::ACTION_ATTACH) {
return $xlvask->config->automatic_order_attachment_enabled->isTrue() 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) { if ($action === self::ACTION_CREATE) {
return $xlvask->config->automatic_order_creation_enabled->isTrue() return false;
&& $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 private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
{ {
try { try {
@@ -646,7 +686,7 @@ class xlvask_automation_service
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion; $latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
if ($automatic) { 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); 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 private function createOrderFromContext(array $context): orders_o
{ {
$orderData = $context['proposed_order']; $orderData = $context['proposed_order'];
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
{ {
global $db; global $db;
(new xlvask_usage_logs_o())->structure(); (new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = [ $where = [
'FinishStatus = 1', 'FinishStatus = 1',
'(ignored_at IS NULL OR ignored_at = "")', '(ignored_at IS NULL OR ignored_at = "")',
]; ];
if ($dateFrom !== null && strtotime($dateFrom) !== false) { 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 { } 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) { 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)); $limit = max(1, min(500, $limit));
+5 -2
View File
@@ -39,8 +39,11 @@ $htaccess = "
# php -- END cPanel-generated handler, do not edit # 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.) // Keep the compatibility rewrite file stable without rewriting it every minute.
file_put_contents(__DIR__ . '/.htaccess', $htaccess); $htaccessPath = __DIR__ . '/.htaccess';
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
file_put_contents($htaccessPath, $htaccess);
}
// Log the time of the cron job // 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); file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
+293 -13
View File
@@ -8,6 +8,7 @@ use classes\invoice_period_flag_service;
use classes\coolify_manager; use classes\coolify_manager;
use classes\replication_manager; use classes\replication_manager;
use classes\redis; use classes\redis;
use classes\selfserve;
use classes\system_search_cache; use classes\system_search_cache;
use classes\system_search_document_index; use classes\system_search_document_index;
use classes\system_search_economic_customer_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_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php'; require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.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')) { if (!defined('WD')) {
exit; exit;
@@ -652,7 +658,6 @@ function SystemSearchCacheMaintenanceCron(): void
if ($rebuildRequest !== null) { if ($rebuildRequest !== null) {
system_search_cache::clearQueryCaches(); system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$scope = (string)($rebuildRequest['scope'] ?? 'all'); $scope = (string)($rebuildRequest['scope'] ?? 'all');
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? [])))); $types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
if ($scope === 'types' && !empty($types)) { if ($scope === 'types' && !empty($types)) {
@@ -1470,17 +1475,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
} }
} }
foreach ( $cron_tasks as $task => $data ) { function SelfserveOpeningRelayActivationCron(): array
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task); {
$nextRun = $lastRun + $data['interval']; global $db;
$cron_tasks[$task]['last_run'] = $lastRun;
$cron_tasks[$task]['next_run'] = $nextRun; if (!($db instanceof \classes\db)) {
if ($nextRun <= time()) { warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n"; return [
$data['function'](); 'skipped' => true,
redis->set_last_crond_run($task, time()); 'reason' => 'database_unavailable',
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)';
$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": { "post": {
"tags": ["User Bookings"], "tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate", "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" } ], "parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": { "responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } }, "200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -146,7 +146,7 @@
"get": { "get": {
"tags": ["User Bookings"], "tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate PDF", "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" } ], "parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": { "responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } }, "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 $message;
public null|string $corporateIdentificationNumber; public null|string $corporateIdentificationNumber;
public null|string $email; public null|string $email;
public null|string $ean;
public null|string $publicEntryNumber;
public null|string $mobilePhone; public null|string $mobilePhone;
public null|string $currency; public null|string $currency;
public null|string $country; public null|string $country;
@@ -46,6 +48,8 @@ class economic_customer_mo
$this->zip = ($customer->zip ?? null); $this->zip = ($customer->zip ?? null);
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null); $this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
$this->email = ($customer->email ?? 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->mobilePhone = ($customer->mobilePhone ?? null);
$this->currency = ($customer->currency ?? null); $this->currency = ($customer->currency ?? null);
$this->country = ($customer->country ?? null); $this->country = ($customer->country ?? null);
@@ -100,6 +104,8 @@ class economic_customer_mo
'zip' => $this->zip, 'zip' => $this->zip,
'corporateIdentificationNumber' => $this->corporateIdentificationNumber, 'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
'email' => $this->email, 'email' => $this->email,
'ean' => $this->ean,
'publicEntryNumber' => $this->publicEntryNumber,
'mobilePhone' => $this->mobilePhone, 'mobilePhone' => $this->mobilePhone,
'currency' => $this->currency, 'currency' => $this->currency,
'country' => $this->country, 'country' => $this->country,
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
$customer_address = $customer->getAddress() ?? 'Ukendt'; $customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt'; $customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? '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 // Send the request
$response = $this->send_request( $response = $this->send_request(
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
'currency' => $customer->getCurrency() ?? 'DKK', 'currency' => $customer->getCurrency() ?? 'DKK',
// Set the recipient details // Set the recipient details
'recipient' => [ 'recipient' => $recipient,
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
],
]) ])
); );
// Return the response as an object // 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; 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 * Get the customer address
* @return string The customer address * @return string The customer address
@@ -227,4 +250,4 @@ class economic_customer
return $this->customer_data_object->vatZone->vatZoneNumber; return $this->customer_data_object->vatZone->vatZoneNumber;
} }
} }
@@ -799,7 +799,6 @@ class edge_gateway_manager
$existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ $existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
'gateway_id' => $gatewayId, 'gateway_id' => $gatewayId,
'relay_id' => $relayId, 'relay_id' => $relayId,
'deleted_at' => null,
], ['id']); ], ['id']);
if ($existing !== []) { if ($existing !== []) {
@@ -818,6 +817,7 @@ class edge_gateway_manager
$bindingObject->approved_by->set($userId); $bindingObject->approved_by->set($userId);
$bindingObject->approved_at->set($this->now()); $bindingObject->approved_at->set($this->now());
$bindingObject->metadata_json->set($bindingMetadata); $bindingObject->metadata_json->set($bindingMetadata);
$bindingObject->deleted_at->set(null);
continue; 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; return null;
} }
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
if (!$session->markCompletedIfOpen($orderId)) { if (!$session->markCompletedIfOpen($orderId)) {
return $this->getSessionSummary((int)$session->id); 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); 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 public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
{ {
$lane = (new selfserve())->lane($laneId); $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 private function unsetNullifiableProperties(): void
{ {
$nullable_review_metadata = [
'ignored_at',
'ignored_reason',
];
// Unset properties that are null or empty strings // Unset properties that are null or empty strings
$properties = [ $properties = [
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', 'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
if ($this->isEmptyOrDefault($this->{$property})) { if ($this->isEmptyOrDefault($this->{$property})) {
$tmp_value = $this->{$property}; $tmp_value = $this->{$property};
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) { 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) { } elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
if ($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 $this->{$property} = null; // Set to null if it matches the default int nullable
@@ -490,18 +490,19 @@ class customer_vehicles_o extends db
return []; return [];
} }
//print_r($transaction_ids); //print_r($transaction_ids);
// Convert the array of transaction ids to a comma separated string $orders = [];
$orders = '';
foreach ($transaction_ids as $transaction_id) { foreach ($transaction_ids as $transaction_id) {
// Check if the transaction is included in the invoicing. // Check if the transaction is included in the invoicing.
$tmp = (new orders_o())->select((int)$transaction_id); $tmp = (new orders_o())->select((int)$transaction_id);
if (!$tmp->isIncludedInInvoicing()) { if (!$tmp->isIncludedInInvoicing()) {
continue; // The transaction is not included in the invoicing, skip it continue; // The transaction is not included in the invoicing, skip it
} }
$orders .= (int)$transaction_id . ','; $orders[] = (int)$transaction_id;
} }
// Remove the last comma if (empty($orders)) {
$orders = rtrim($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 // 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 = " $query = "
SELECT o.id SELECT o.id
@@ -509,7 +510,7 @@ class customer_vehicles_o extends db
JOIN order_items oi ON oi.order_id = o.id JOIN order_items oi ON oi.order_id = o.id
WHERE o.deleted_at IS NULL WHERE o.deleted_at IS NULL
AND oi.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() . " AND oi.product_id = " . (int)$this->type->value() . "
GROUP BY o.id GROUP BY o.id
ORDER BY o.created_at ASC 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; namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use classes\db; use classes\db;
use classes\department_wash_count_service;
use classes\object_property; use classes\object_property;
use Exception; use Exception;
use traits\db_object_t; 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 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) { 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 the date_to is null, set it to the date
if ($date_to === null) { if ($date_to === null) {
$date_to = $date; // Making the report for an entire day $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 // Set the date time to cover the entire day
$date = date('Y-m-d 00:00:00', strtotime($date)); $date = date('Y-m-d 00:00:00', strtotime($date));
$date_to = date('Y-m-d 23:59:59', strtotime($date_to)); $date_to = date('Y-m-d 23:59:59', strtotime($date_to));
$conn = $db->conn(); return (new department_wash_count_service())->countInDateRange($date, $date_to, $department_id);
// 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;
}); });
} }
@@ -616,9 +591,6 @@ class department_daily_reports_o extends db
*/ */
public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array 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); $normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
if ($normalized_department_ids === []) { if ($normalized_department_ids === []) {
return [ return [
@@ -631,30 +603,17 @@ class department_daily_reports_o extends db
} }
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to); [$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
$department_ids_sql = implode(',', $normalized_department_ids); $transaction_summary = (new department_wash_count_service())->transactionSummary(
$escaped_start = $db->escape_string($date_start); $date_start,
$escaped_end = $db->escape_string($date_end); $date_end,
$normalized_department_ids
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity, );
COALESCE(SUM(oi.quantity), 0) AS products,
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings,
COUNT(DISTINCT CASE WHEN p.is_wash = 1 THEN o.id END) AS washes
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL";
$result = $db->query($sql);
$row = is_object($result) ? $result->fetch_assoc() : null;
return [ return [
'quantity' => (int)($row['quantity'] ?? 0), 'quantity' => (int)($transaction_summary['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0), 'products' => (int)($transaction_summary['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)), 'earnings' => (int)($transaction_summary['earnings'] ?? 0),
'washes' => (int)($row['washes'] ?? 0), 'washes' => (int)($transaction_summary['washes'] ?? 0),
'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to), '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 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); $normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
if ($normalized_department_ids === []) { if ($normalized_department_ids === []) {
return []; return [];
} }
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to); [$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
$department_ids_sql = implode(',', $normalized_department_ids); return (new department_wash_count_service())->listTransactions($date_start, $date_end, $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;
} }
/** /**
@@ -381,7 +381,7 @@ class order_bookings_o extends db
} }
$this->attachWashCertificate($user_id, $order->getSafetySealValue()); $this->attachWashCertificate($user_id, $order->getSafetySealValue());
if ($order->hasWashCertificateAttached()) { if ($this->getOrder()->hasWashCertificateAttached()) {
$this->sendWashCertificateToCustomer(); $this->sendWashCertificateToCustomer();
} }
} }
@@ -585,7 +585,7 @@ class order_bookings_o extends db
return !empty($this->order_id->value()); 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 */ global /** @var db $db */
$db; $db;
+3 -4
View File
@@ -177,13 +177,12 @@ class order_items_o extends db
// Check if the user has a discount on the product, or category // Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id); $customer = (new orders_o())->getOrderCustomer($order_id);
$discount = $customer->getCustomPrice($product_id, false); if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
if ($discount && !products_o::priceResolutionIsCustomMissing($priceResolution)) { $price = $customer->applyProductCustomerPricing($product_id, (int)$price, false, (int)$order->department_id->value());
$price = $price - ($price * $discount / 100);
} }
// If the price is forced, set the price to the forced price // If the price is forced, set the price to the forced price
if ($forcePrice) { if ($forcePrice !== null) {
$price = (int)$forcePrice; $price = (int)$forcePrice;
} }
+8 -88
View File
@@ -2,8 +2,11 @@
namespace objects; namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use attachments\helpers\attachment_content; use attachments\helpers\attachment_content;
use classes\db; use classes\db;
use classes\department_wash_count_service;
use classes\email; use classes\email;
use classes\invoicing_period_utils; use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap; use classes\orders_schema_bootstrap;
@@ -1435,10 +1438,8 @@ class orders_o extends db
if (!$user->exists()) { if (!$user->exists()) {
throw new Exception('No user found matching the customer number in the usage log'); 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
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) { if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price $product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value());
} }
$order_item->notes->set(null); // Set notes for the simulated order item $order_item->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->price->set((int)$product_price); // Set the price based on the product price and discount percentage
@@ -1508,13 +1509,10 @@ class orders_o extends db
} }
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value()); $priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$price = (int)$priceResolution['price']; // Get the department price for the product $price = (int)$priceResolution['price']; // 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
if (products_o::priceResolutionIsCustomMissing($priceResolution)) { if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price; return $price;
} }
return (int)round($price * (1 - ($discount_percentage / 100))); return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value());
} }
/** /**
@@ -1601,10 +1599,9 @@ class orders_o extends db
if ($tmp_user === null) { if ($tmp_user === null) {
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); $tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
} }
$discount = $tmp_user->getCustomPrice($product_id, false);
$unitPrice = (int)$department_price_cache[$product_id]['price']; $unitPrice = (int)$department_price_cache[$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) { if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
$unitPrice = (int)round($unitPrice * (1 - ($discount / 100))); $unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id);
} }
$post_discount = $unitPrice * $quantity; $post_discount = $unitPrice * $quantity;
$total += $post_discount; $total += $post_discount;
@@ -2011,33 +2008,7 @@ class orders_o extends db
public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int
{ {
global /** @var db $db */ return (new department_wash_count_service())->countInDateRange($date_start, $date_end, $department_id);
$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'];
} }
/** /**
@@ -2047,58 +2018,7 @@ class orders_o extends db
*/ */
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{ {
global /** @var db $db */ return (new department_wash_count_service())->countByHourForDepartments($date_start, $date_end, $department_ids);
$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;
} }
/** /**
+5 -8
View File
@@ -326,24 +326,21 @@ class products_o extends db
return $this->getDepartmentPriceResolution($department_id)['price']; return $this->getDepartmentPriceResolution($department_id)['price'];
} }
public function applyCustomerDiscounts(array $products, users_o $customer): array public function applyCustomerDiscounts(array $products, users_o $customer, ?int $departmentId = null): array
{ {
global $db; global $db;
// Get the customer's discounts // 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; global $db;
// Validate input // Validate input
if (!isset($product['id']) || !isset($product['price'])) { if (!isset($product['id']) || !isset($product['price'])) {
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys'); throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
} }
// Get the customer's discount percentage if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$discount_percentage = $customer->getProductDiscountPercentage($product['id']); $product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price'], true, $departmentId);
// Apply the discount to the product price
if ($discount_percentage > 0 && ($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
} }
unset($product[self::PRICE_SOURCE_KEY]); unset($product[self::PRICE_SOURCE_KEY]);
return $product; return $product;
@@ -4,6 +4,8 @@ namespace objects;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
use classes\price_overrides_schema_bootstrap;
use classes\system_search_cache;
use traits\db_object_t; use traits\db_object_t;
class user_price_overrides_o extends db 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 $is_category;
public object_property $product_or_category_id; public object_property $product_or_category_id;
public object_property $percentage; public object_property $percentage;
public object_property $fixed_price;
public function structure(): void public function structure(): void
{ {
$this->setTable('price_overrides'); $this->setTable('price_overrides');
price_overrides_schema_bootstrap::ensureColumns();
} }
public function objectChanged(): void 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->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->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->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 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 bool $is_category
* @param int|string $product_or_category_id * @param int|string $product_or_category_id
* @param int $percentage * @param int $percentage
* @param int|null $fixed_price
* @return $this * @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; global $db;
// If the user is not set, return the object // If the user is not set, return the object
if (!isset($this->user_id)) { if (!isset($this->user_id)) {
return $this; return $this;
} }
if ($is_category) {
$fixed_price = null;
}
// Check if the record already exists // Check if the record already exists
$this->removePriceIfExist($is_category, $product_or_category_id); $this->removePriceIfExist($is_category, $product_or_category_id);
// If the percentage is 0, return the object // If neither a discount nor a fixed product price is set, remove the record.
if ($percentage === 0) { if ($percentage === 0 && $fixed_price === null) {
return $this; return $this;
} }
// Create a new record in the database // 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); $db->query($sql);
$this->markSearchDirty();
return $this; return $this;
} }
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
{ {
global $db; global $db;
// Get the price override from the database $product_or_category_id = $db->escape_string((string)$product_or_category_id);
$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'"; $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'";
$result = $db->query($sql); $db->query($sql);
$this->markSearchDirty();
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);
}
} }
/** /**
@@ -132,6 +139,49 @@ class user_price_overrides_o extends db
return $percentage; 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 * Get all the price overrides for the user
* @return array * @return array
@@ -153,6 +203,7 @@ class user_price_overrides_o extends db
$row['is_category'] = (bool)$row['is_category']; $row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = (int)$row['product_or_category_id']; $row['product_or_category_id'] = (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage']; $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['created_at'] = (string)$row['created_at'];
$row['updated_at'] = (string)$row['updated_at']; $row['updated_at'] = (string)$row['updated_at'];
// Add the row to the list // Add the row to the list
@@ -167,10 +218,19 @@ class user_price_overrides_o extends db
'is_category' => true, 'is_category' => true,
'product_or_category_id' => "global", 'product_or_category_id' => "global",
'percentage' => (int)$economic_user_global_discount, 'percentage' => (int)$economic_user_global_discount,
'fixed_price' => null,
'created_at' => "2021-01-01 00:00:00", 'created_at' => "2021-01-01 00:00:00",
'updated_at' => "2021-01-01 00:00:00" 'updated_at' => "2021-01-01 00:00:00"
]; ];
} }
return $prices; return $prices;
} }
}
private function markSearchDirty(): void
{
try {
system_search_cache::markDirtyTable($this->table);
} catch (\Throwable) {
}
}
}
+138 -4
View File
@@ -936,9 +936,18 @@ class users_o extends db
* @param bool $is_category If the object is a category * @param bool $is_category If the object is a category
* @return int|null The discount percentage * @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(); 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 // Get the custom price for the product
$discount = $this->price_overrides->setUser($this->id)->getPrice($is_category, $object_id); $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 // 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) * - Category discount (If the product allows category inheritance of discounts)
* - Product discount (If the product has a custom price) * - 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(); 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 // Get the product by ID
$product = (new products_o())->select((int)$product_id); $product = (new products_o())->select((int)$product_id);
$doesProductAllowCategoryDiscount = (bool)$product->apply_category_discount->value(); $doesProductAllowCategoryDiscount = (bool)$product->apply_category_discount->value();
@@ -981,6 +993,71 @@ class users_o extends db
return $discount_percentage === null ? 0 : (int)$discount_percentage; 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; 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 public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
{ {
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) { 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']); $listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
} }
return $listObjectsWithPaginationIfSet; return $listObjectsWithPaginationIfSet;
@@ -1183,15 +1316,16 @@ class users_o extends db
* @param int $object_id The ID of the object * @param int $object_id The ID of the object
* @param int $discount_percentage The discount percentage * @param int $discount_percentage The discount percentage
* @param bool $is_category If the object is a category * @param bool $is_category If the object is a category
* @param int|null $fixed_price The fixed product price, when set
* @return void * @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; $this->id = $user_id;
// Get the user object properties // Get the user object properties
$this->getObjectProperties(); $this->getObjectProperties();
// Set the custom price (key = 'custom_price') // 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 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->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', '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->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_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); $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 * 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. * @throws Exception If the objects were not successfully added.
* @returns void * @returns void
*/ */
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
{ {
if (!empty($this->id)) { if (!empty($this->id)) {
throw new Exception('To prevent issues, having a selected object is not allowed.'); throw new Exception('To prevent issues, having a selected object is not allowed.');
} }
$usage_logs = $this->getUsageLogsFromXLVask( $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 */ /** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
$known_usage_logIds = array_map(function ($log) { $known_usage_logIds = array_map(function ($log) {
return $log['WashId']; return $log['WashId'];
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
unset($new_usage_logs); 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 * 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') * @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
File diff suppressed because it is too large Load Diff
@@ -1733,13 +1733,13 @@ class InvoicingPeriodRoute
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id); $department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
} }
if (!array_key_exists($product_id, $discount_cache)) { 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;
} }
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price']; $post_discount = (int)$discount_cache[$product_id] * $quantity;
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
$unit_price = (int)round($unit_price * (1 - ($discount_cache[$product_id] / 100)));
}
$post_discount = $unit_price * $quantity;
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount); $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
} }
+9
View File
@@ -427,6 +427,7 @@ class authRoute
$contactEmail = self::getParameter('contactEmail'); $contactEmail = self::getParameter('contactEmail');
$contactPhone = (int)self::getParameter('contactPhone'); $contactPhone = (int)self::getParameter('contactPhone');
$contactName = self::getParameter('contactName'); $contactName = self::getParameter('contactName');
$ean = null;
/** /**
* Validate * Validate
*/ */
@@ -454,6 +455,13 @@ class authRoute
self::requireMinValue($contactPhone, 10000000); self::requireMinValue($contactPhone, 10000000);
self::requireMaxValue($contactPhone, 9999999999); 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 * If the contact phone is empty, default to company phone
@@ -545,6 +553,7 @@ class authRoute
(int)$companyPhone, (int)$companyPhone,
(int)$contactPhone, (int)$contactPhone,
$companyInformation, $companyInformation,
$ean,
); );
} catch (Exception $exception) { } catch (Exception $exception) {
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure( $recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
+17 -8
View File
@@ -291,12 +291,11 @@ class bookingsRoute
// Require the user to be logged in // Require the user to be logged in
global /** @var response $response */ global /** @var response $response */
$response; $response;
// Check if the user has access to the department $this->requireOwnWashCertificateDownloadAccess();
$this->requirePermission('download_own_wash_certificate');
// Get the user object // Get the user object
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
// Check if the request was successful // Check if the request was successful
if (!$user->exists()) { if ($user === false || !$user->exists()) {
$response->error('User not found', 400); $response->error('User not found', 400);
} }
// Check if the required fields are set // 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 // Require the user to be logged in
global /** @var response $response */ global /** @var response $response */
$response; $response;
// Check if the user has access to the department $this->requireOwnWashCertificateDownloadAccess();
$this->requirePermission('download_own_wash_certificate');
// Get the user object // Get the user object
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
// Check if the request was successful // Check if the request was successful
if (!$user->exists()) { if ($user === false || !$user->exists()) {
$response->error('User not found', 400); $response->error('User not found', 400);
} }
self::requireParameters(['id']); self::requireParameters(['id']);
@@ -401,7 +399,7 @@ class bookingsRoute
} }
}, },
[ [
'download_own_wash_certificate' => 'Download the wash certificate for a booking' 'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
] ]
); );
@@ -530,4 +528,15 @@ class bookingsRoute
] ]
); );
} }
private function requireOwnWashCertificateDownloadAccess(): void
{
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false && $user->exists() && $this->hasPermission('user')) {
return;
}
$this->requirePermission('download_own_wash_certificate');
}
} }
+141 -26
View File
@@ -3,7 +3,9 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\cron_scheduler;
use objects\logs_o; use objects\logs_o;
use Throwable;
use traits\route_t; use traits\route_t;
class cronRoute class cronRoute
@@ -12,39 +14,152 @@ class cronRoute
public function run(): void public function run(): void
{ {
$this->post('/superuser/cron', function () { $this->get('/superuser/cron', function () {
// Get the post data
global $response; global $response;
// Make sure the user has the SUPERUSER_RUN_CRON permission
$this->requirePermission('SUPERUSER_RUN_CRON'); $this->requireClassicSuperuserPermission('superuser_cron_view');
// Get the user object $response->success((new cron_scheduler())->listTasks());
$user = (new authentication())->get_user(); }, [
// Get the post data 'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
$data = json_decode(file_get_contents('php://input'), true); ]);
// Check if a specific cron job is requested
if (isset($data['job'])) { $this->get('/superuser/cron/runs', function () {
// Check if the cron job exists global $response;
if (!file_exists(WD . '/cron/' . $data['job'] . '.php')) {
$response->error('Cron job not found', 404); $this->requireClassicSuperuserPermission('superuser_cron_view');
} $task_id = $this->getParameter('task_id');
// Include the cron job $limit = (int)($this->getParameter('limit') ?? 50);
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');
$response->success([ $response->success([
'message' => 'All cron jobs ran successfully', 'runs' => (new cron_scheduler())->listRuns(is_string($task_id) ? $task_id : null, $limit),
'data' => $response_cron ?? []
]); ]);
}, [
'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' '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 () { $this->get('/customer/attributes', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
$this->requirePermission('list_customer_attributes');
// Get the user object // 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 // Check if the request was successful
if ($user) { if ($user || $subuser) {
// Get the query parameters from the URL // Get the query parameters from the URL
$data = $_GET; $data = $_GET;
// Check if the required fields are set // Check if the required fields are set
@@ -32,14 +33,19 @@ class customerAttributes
$response->error('Customer Number must be a number', 400); $response->error('Customer Number must be a number', 400);
} }
// Check if the user exists // 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); $response->error('Customer not found', 400);
} }
if (!$this->canListTargetCustomerAttributes($target_user)) {
$this->requirePermission('list_customer_attributes');
}
// Log the incident // 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 // Return the list of customer notes
$response->success( $response->success(
(new users_o())->automaticGetTargetUserFromRequest()->getUserAttributes() $target_user->getUserAttributes()
); );
} else { } else {
// Log the incident // 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 Exception;
use objects\department_daily_report_complaints_o; use objects\department_daily_report_complaints_o;
use objects\department_daily_reports_o; use objects\department_daily_reports_o;
use objects\department_variables_o;
use objects\departments_o; use objects\departments_o;
use objects\logs_o; use objects\logs_o;
use objects\users_o; use objects\users_o;
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
{ {
use route_t; use route_t;
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
public function run(): void public function run(): void
{ {
$this->get('/departments/daily-reports', function () { $this->get('/departments/daily-reports', function () {
@@ -850,7 +853,8 @@ class departmentDailyReportsRoute
'overview' => $this->buildDailyReportOverview( 'overview' => $this->buildDailyReportOverview(
[$department_id], [$department_id],
(string)self::getParameter('date'), (string)self::getParameter('date'),
$date_to $date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
), ),
]); ]);
}, },
@@ -895,7 +899,8 @@ class departmentDailyReportsRoute
$this->buildDailyReportOverview( $this->buildDailyReportOverview(
$department_ids, $department_ids,
(string)self::getParameter('date'), (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 () { $this->get('/departments/daily-reports/product-count', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
@@ -1369,7 +1441,7 @@ class departmentDailyReportsRoute
* } * }
* @throws Exception * @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(); $repository = $this->dailyReportRepository();
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to); $transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
@@ -1389,6 +1461,11 @@ class departmentDailyReportsRoute
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to); $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( return $this->assembleDailyReportOverview(
$department_ids, $department_ids,
$date, $date,
@@ -1399,7 +1476,8 @@ class departmentDailyReportsRoute
$product_summary_lookup, $product_summary_lookup,
$complaints_metric, $complaints_metric,
$night_wash_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> $complaints_metric
* @param array<string,mixed> $night_wash_metric * @param array<string,mixed> $night_wash_metric
* @param array<string,mixed> $overtime_metric * @param array<string,mixed> $overtime_metric
* @param array<int,float> $product_target_lookup
* @return array{ * @return array{
* department_ids:array<int>, * department_ids:array<int>,
* date:string, * date:string,
@@ -1430,7 +1509,8 @@ class departmentDailyReportsRoute
array $product_summary_lookup, array $product_summary_lookup,
array $complaints_metric, array $complaints_metric,
array $night_wash_metric, array $night_wash_metric,
array $overtime_metric array $overtime_metric,
array $product_target_lookup = []
): array { ): array {
$products = []; $products = [];
foreach ($product_definitions as $definition) { foreach ($product_definitions as $definition) {
@@ -1448,6 +1528,12 @@ class departmentDailyReportsRoute
'state' => 'ready', 'state' => 'ready',
'value' => (int)($product_summary['quantity'] ?? 0), 'value' => (int)($product_summary['quantity'] ?? 0),
'out_of' => (int)($product_summary['out_of'] ?? 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); 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}> * @return array<int,array{product_id:int,slug:string,title:string}>
*/ */
+20 -7
View File
@@ -575,11 +575,6 @@ class departmentsRoute
} }
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
{ {
if (!$enabled) {
// Self-serve disabled: do not mutate lane relay states.
return;
}
$selfserve = new selfserve(); $selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId); $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
@@ -595,12 +590,30 @@ class departmentsRoute
continue; 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 { $this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatus(false); $lane->setMachineProgramPickerRelayStatus(false);
}); });
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { $this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatus(false); $lane->setMachineCleanerRelayStatusHard(true);
}); });
try { try {
$lane->setMachineRelayStatus(false); $lane->setMachineRelayStatus(false);
@@ -44,11 +44,38 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices', 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->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_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); $this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->rolePresets(); return $service->rolePresets($user);
}); });
}, [ }, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', 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', 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->put('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -134,4 +181,59 @@ class limitedBackofficeRoute
} }
return (int)$value; 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::requireMaxLength('phone', 255);
self::requireMinLength('name', 1); self::requireMinLength('name', 1);
self::requireMaxLength('name', 255); 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'); (new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer');
$result = (new economic())->createCustomer( $result = (new economic())->createCustomer(
(int)self::getParameter('customer_number'), (int)self::getParameter('customer_number'),
@@ -67,6 +75,9 @@ class moduleEconomicCustomerRoute
(int)self::getParameter('cvr'), (int)self::getParameter('cvr'),
(string)self::getParameter('email'), (string)self::getParameter('email'),
(int)self::getParameter('phone'), (int)self::getParameter('phone'),
null,
null,
$ean,
); );
$response->success((object)$result); $response->success((object)$result);
} else { } else {
@@ -76,4 +87,4 @@ class moduleEconomicCustomerRoute
}); });
} }
} }
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/import-usage', function () { $this->get('/modules/xlvask/tasks/import-usage', function () {
global $response; global $response;
self::requirePermission('modules_xlvask_import_usage'); 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 // Create the xlvask_usage_logs_o object
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o(); $xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
// Import usage logs // Import usage logs
$xlvask_usage_logs_o->importUsageLogs(); $xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
(new xlvask_automation_service())->runPending(null, null, [], 100, null); (new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
// Response // Response
$response->success( $response->success(
'Usage logs imported', 'Usage logs imported',
@@ -40,11 +40,15 @@ class orderBookingRoute
$reference = self::getTargetReference(); // String | Null $reference = self::getTargetReference(); // String | Null
$po = self::getTargetPo(); // String | Null $po = self::getTargetPo(); // String | Null
$pickup = self::getTargetPickup(); // Bool | Null $pickup = self::getTargetPickup(); // Bool | Null
$items = self::getTargetItems(); // Array of order_items_o objects
$this->requireOrderBookingCreateAccess( $this->requireOrderBookingCreateAccess(
(int)$customer_number->customer_number->value(), (int)$customer_number->customer_number->value(),
(int)$department->id (int)$department->id
); );
$items = self::getTargetItems(
true,
$customer_number,
(int)$department->id
); // Array of order_items_o objects
/** /**
* Input data * Input data
*/ */
@@ -87,7 +91,8 @@ class orderBookingRoute
$response->success($order_bookings_o->asArray()); $response->success($order_bookings_o->asArray());
}, },
[ [
'add_bookings' => 'Permission to create order bookings for another customer or department scope.' '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.'
] ]
); );
@@ -256,7 +261,6 @@ class orderBookingRoute
$reference = self::getTargetReference(false); // String | Null $reference = self::getTargetReference(false); // String | Null
$po = self::getTargetPo(false); // String | Null $po = self::getTargetPo(false); // String | Null
$pickup = self::getTargetPickup(false); // Bool | 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_was_set = self::isParametersSet(['order_id']);
$order_id = self::getTargetOrderId(false); // Int | Null $order_id = self::getTargetOrderId(false); // Int | Null
/** Authentication */ /** Authentication */
@@ -283,6 +287,11 @@ class orderBookingRoute
$ownGuard, $ownGuard,
'You do not have permission to edit this order booking.' '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 * Update the object
*/ */
@@ -651,7 +660,21 @@ class orderBookingRoute
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
{ {
if ($this->isOrderBookingCustomerSession() && $this->isOwnCustomerContext($targetCustomerNumber)) { $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; return;
} }
@@ -781,7 +804,11 @@ class orderBookingRoute
/** /**
* @throws Exception * @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; global $response;
$parameter = 'items'; $parameter = 'items';
$error = 'Invalid items'; $error = 'Invalid items';
@@ -796,11 +823,52 @@ class orderBookingRoute
foreach ($items as $key => $item) { foreach ($items as $key => $item) {
self::requireType($item, self::type_array()); self::requireType($item, self::type_array());
self::requireValidItem((array)$item, $key); 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; 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 private function cleanReg(string $reg): string
{ {
// Remove all non-alphanumeric characters // 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_compare_engine;
use classes\economic_v2_line_normalizer; use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service; use classes\economic_v2_revenue_statistics_service;
use classes\invoice_collection_bulk_action_service;
use classes\invoicing_period_utils; use classes\invoicing_period_utils;
use classes\response; use classes\response;
use classes\router; use classes\router;
@@ -711,6 +712,105 @@ class orderInvoicesRoute
] ]
); );
/** Collected order invoices > Bulk action preview > POST */
$this->post('/collected-invoices/bulk-actions/preview', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'PREVIEW_COLLECTED_INVOICE_BULK_ACTION', 'User tried to preview a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['action', 'invoice_collection_ids']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$preview = (new invoice_collection_bulk_action_service())->preview(
$action,
$invoice_collection_ids,
$options,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user->id,
'PREVIEW_COLLECTED_INVOICE_BULK_ACTION',
'User previewed collected invoice bulk action ' . $action . ' for ' . count($invoice_collection_ids) . ' invoice collections'
);
$response->success($preview);
},
[
'reset_collected_invoice_economic' => 'Preview collected invoice bulk cleanup and price reset actions. This is a superuser-only route.',
'move_collected_invoice' => 'Preview merging selected collected invoices. This is a superuser-only route.',
'split_collected_invoice' => 'Preview splitting selected collected invoices by order month. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Preview queueing selected collected invoices for E-Conomic. This is a superuser-only route.',
]
);
/** Collected order invoices > Bulk action apply > POST */
$this->post('/collected-invoices/bulk-actions/apply', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'APPLY_COLLECTED_INVOICE_BULK_ACTION', 'User tried to apply a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$result = (new invoice_collection_bulk_action_service())->apply(
(string)self::getParameter('preview_id'),
$action,
$invoice_collection_ids,
$options,
(string)self::getParameter('confirmation_text'),
(int)$user->id,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success($result);
},
[
'reset_collected_invoice_economic' => 'Apply collected invoice bulk cleanup and price reset actions after confirmation. This is a superuser-only route.',
'move_collected_invoice' => 'Apply merging selected collected invoices after confirmation. This is a superuser-only route.',
'split_collected_invoice' => 'Apply splitting selected collected invoices by order month after confirmation. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Apply queueing selected collected invoices for E-Conomic after confirmation. This is a superuser-only route.',
]
);
/** Collected order invoices > E-Conomic > POST (queued) */ /** Collected order invoices > E-Conomic > POST (queued) */
$this->post('/collected-invoices/economic', function () { $this->post('/collected-invoices/economic', function () {
global $response; global $response;
@@ -2521,6 +2621,20 @@ class orderInvoicesRoute
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value()); (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 $collected_order_invoice
* @param users_o $users * @param users_o $users
+55 -13
View File
@@ -75,13 +75,12 @@ class orderItemsRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 404); $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']); $product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) { if (!$product->exists()) {
$response->error('Product not found', 404); $response->error('Product not found', 404);
} }
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
$customerRuleViolation = (new customer_product_rule_service()) $customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id); ->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) { if ($customerRuleViolation !== null) {
@@ -95,6 +94,9 @@ class orderItemsRoute
); );
$response->error($customerRuleViolation['message'], 400); $response->error($customerRuleViolation['message'], 400);
} }
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible // Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o()); $order_items = (new order_items_o());
@@ -173,18 +175,38 @@ class orderItemsRoute
$this->delete('/order/items', function () { $this->delete('/order/items', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response, $db;
$this->requirePermission('delete_order_items'); $this->requirePermission('delete_order_items');
// Get the user object // Get the user object
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
// Check if the request was successful // Check if the request was successful
if ($user) { if ($user) {
// Get the query data // Get the order item id from the query string or request body
$data = $_GET; $itemIdRaw = $this->fromRequest('id');
// Check if the required fields are set if ($itemIdRaw === null || $itemIdRaw === '') {
if (!isset($data['id'])) {
$response->error('Order Item ID is required', 400); $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 // Delete the order item
(new order_items_o())->removeOrderItem((int)$data['id']); (new order_items_o())->removeOrderItem((int)$data['id']);
// Return the list of departments // Return the list of departments
@@ -205,7 +227,7 @@ class orderItemsRoute
$this->put('/order/items', function () { $this->put('/order/items', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response, $db;
$this->requirePermission('edit_order_items'); $this->requirePermission('edit_order_items');
// Get the user object // Get the user object
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
@@ -230,19 +252,39 @@ class orderItemsRoute
$response->error('Quantity is required', 400); $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()) { if (!$orderItem->exists()) {
$response->error('Order item not found', 404); $response->error('Order item not found', 404);
} }
$product = (new products_o())->getProductById((int)$orderItem->product_id->value()); $orderItemContextResult = $db->query(
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') { "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); $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()) { if (!$order->exists()) {
$response->error('Order not found', 404); $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'); $canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) { if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400); $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 // Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']); $targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) { if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $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 // Get the base64 file
$base64_file = (string)$this->getParameter('base64_file'); $base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store(); $attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $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 // Delete the attachment
$order->removeAttachment((int)$attachment_id); $order->removeAttachment((int)$attachment_id);
// Log the incident // Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $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 // Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value()); $order->markAsCompleted((string)$user->display_name->value());
// Log the incident // Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
} }
// Admin/department path (requires edit_order) // Admin/department path (requires edit_order)
self::requirePermission($permission_other); 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(); $originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber; $newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false; $shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400); $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']); $order->department_id->set((int)$data['department_id']);
} }
// If the booking ID is set, validate it // If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{ {
try { try {
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) { return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
} catch (\Throwable) { } catch (\Throwable) {
return false; return false;
} }
+188 -87
View File
@@ -20,50 +20,121 @@ class productsRoute
* Get the customer object if the customer_id parameter is provided (In the request 'customer_id') * Get the customer object if the customer_id parameter is provided (In the request 'customer_id')
* @return users_o|null * @return users_o|null
*/ */
private function getCustomerIfProvided(): ?users_o private function getCustomerIfProvided(bool $restrictToOwnCustomer = false): ?users_o
{ {
global $response; $customerId = $this->getCustomerIdIfProvided();
if (self::isParametersSet(['customer_id'])) { if ($customerId === null) {
$customerId = (int)self::getParameter('customer_id'); return null;
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;
}
} }
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; 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') * Get the department id if the department_id parameter is provided (In the request 'department_id')
* @return int|null * @return int|null
*/ */
private function getDepartmentIdIfProvided(): ?int private function getDepartmentIdIfProvided(): ?int
{ {
global $response; return $this->getOptionalPositiveIntParameter('department_id');
if (self::isParametersSet(['department_id'])) {
return (int)self::getParameter('department_id');
}
return null;
} }
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void private function getOptionalPositiveIntParameter(string $parameter): ?int
{ {
if (!$user instanceof users_o || $departmentId === null) { global $response;
return; if (!self::isParametersSet([$parameter])) {
return null;
} }
if ($this->hasPermission('superuser_fetch_department')) { $value = self::getParameter($parameter);
return; if ($this->isNullLikeOptionalParameter($value)) {
return null;
} }
$this->requirePermission('department_access_' . $departmentId); $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);
}));
} }
/** /**
@@ -72,11 +143,7 @@ class productsRoute
*/ */
private function getCategoryIfProvided(): ?int private function getCategoryIfProvided(): ?int
{ {
global $response; return $this->getOptionalPositiveIntParameter('category');
if (self::isParametersSet(['category'])) {
return (int)self::getParameter('category');
}
return null;
} }
/** /**
@@ -85,11 +152,7 @@ class productsRoute
*/ */
private function getProductIdIfProvided(): ?int private function getProductIdIfProvided(): ?int
{ {
global $response; return $this->getOptionalPositiveIntParameter('id');
if (self::isParametersSet(['id'])) {
return (int)self::getParameter('id');
}
return null;
} }
/** /**
@@ -109,7 +172,7 @@ class productsRoute
// Check if the customer is set // Check if the customer is set
if ($customer !== null) { if ($customer !== null) {
// Apply the customers unique discounts // Apply the customers unique discounts
$products = (new products_o())->applyCustomerDiscounts($products, $customer); $products = (new products_o())->applyCustomerDiscounts($products, $customer, $departmentId);
} else { } else {
$products = products_o::stripDepartmentPriceSources($products); $products = products_o::stripDepartmentPriceSources($products);
} }
@@ -147,30 +210,44 @@ class productsRoute
global $response; global $response;
$permission_node = 'list_products'; $permission_node = 'list_products';
$isProductDetailsRestricted = true; $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; $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 // Set the user id to 0 if guest
$responsibleUserId = $isProductDetailsRestricted ? 0 : $user->id; $responsibleUserId = $hasAuthenticatedUser ? (int)$user->id : 0;
function parseProduct($product, $isGuest): array $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 = [ $tmpProduct = [
'id' => (int)$product['id'], 'id' => (int)$product['id'],
'name' => (string)$product['name'], 'name' => (string)$product['name'],
'description' => (string)$product['description'], 'description' => (string)$product['description'],
'price' => (int)$product['price'], 'price' => (int)$product['price'],
'subscription_allowed' => (boolean)$product['subscription_allowed'], 'subscription_allowed' => (bool)$product['subscription_allowed'],
'category' => (int)$product['category'], 'category' => (int)$product['category'],
'piktogram' => (string)$product['piktogram'], 'piktogram' => (string)$product['piktogram'],
'economic_product_id' => (int)$product['economic_product_id'], '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), 'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'created_at' => (string)$product['created_at'], 'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'], 'updated_at' => (string)$product['updated_at'],
'addons' => (new product_options_o())->getProductOptions($product['id']), 'addons' => $addons,
'is_wash' => (bool)$product['is_wash'], 'is_wash' => (bool)$product['is_wash'],
'display_in_booking_form' => (bool)$product['display_in_booking_form'], 'display_in_booking_form' => (bool)$product['display_in_booking_form'],
'order_priority' => (int)$product['order_priority'], 'order_priority' => (int)$product['order_priority'],
@@ -205,18 +282,27 @@ class productsRoute
]; ];
} }
return $isGuest ? $tmpProductGuest : $tmpProduct; return $isGuest ? $tmpProductGuest : $tmpProduct;
} };
// Check if the request was successful // Check if the request was successful
if ($user || $isProductDetailsRestricted) { if ($hasAuthenticatedUser || $isSubuserSession || $isProductDetailsRestricted) {
// Define the variables // Define the variables
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided $restrictCustomerBookingProducts = $this->shouldRestrictCustomerBookingProducts($isCustomerBookingSession, $hasListProductsPermission);
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided $useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
$this->assertCanUseDepartmentPricing($user, $departmentId); $customerId = $this->getCustomerIdIfProvided();
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category) $customer = $this->getCustomerIfProvided($restrictCustomerBookingProducts); // This is only used if the customer_id parameter is provided
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product) $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. // 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 // Determine the products to return
if ($category) { if ($category) {
// Get products in the category // Get products in the category
@@ -239,21 +325,24 @@ class productsRoute
} else { } else {
// Get all products // Get all products
$products = (array)(new products_o())->listObjectsWithPaginationIfSet( $products = (array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted) { function ($product) use ($isProductDetailsRestricted, $parseProduct) {
return parseProduct($product, $isProductDetailsRestricted); return $parseProduct($product, $isProductDetailsRestricted);
} }
); );
} }
if ($restrictCustomerBookingProducts && !$productId) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Return all products, with the department pricing and customer discounts applied // Return all products, with the department pricing and customer discounts applied
//$response->success( //$response->success(
// array_map(function ($product) { // array_map(function ($product) {
// return parseProduct($product); // return parseProduct($product);
// }, self::parseProductsPrice($products, $customer, $departmentId)) // }, self::parseProductsPrice($products, $customer, $departmentId))
//); //);
$result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted) { $result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
$productArray = parseProduct($product, $isProductDetailsRestricted); $productArray = $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
// Get the price of the product with the department pricing and customer discounts applied // 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 // Get the options for the product
$productArray['addons'] = self::parseOptionsPrice($productArray['addons'], $customer, $departmentId); $productArray['addons'] = self::parseOptionsPrice($productArray['addons'], $customer, $departmentId);
// Return the product with the updated price // Return the product with the updated price
@@ -267,60 +356,72 @@ class productsRoute
// Log the incident // Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed product with id ' . $response->getRequestParameter('id')); (new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed product with id ' . $response->getRequestParameter('id'));
// Return the product // Return the product
$product = (new products_o())->select((int)self::getParameter('id'))->asArray();
if ($restrictCustomerBookingProducts && !$this->isBookingVisibleProduct($product)) {
$response->success([]);
}
$response->success( $response->success(
parseProduct( $parseProduct(
(new products_o())->select((int)self::getParameter('id'))->asArray(), $isProductDetailsRestricted $product, $isProductDetailsRestricted, $restrictCustomerBookingProducts
) )
); );
} }
// Check if the category is set in the request // Check if the category is set in the request
$data = $_GET ?? [];
// Check if the category is set // Check if the category is set
if (isset($data['category'])) { if ($category !== null) {
// Log the incident // 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 // 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 // Check if the department_id is set
if (isset($data['department_id'])) { if ($departmentId !== null) {
// Apply the departments unique pricing // 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( $response->success(
array_map(function ($product) use ($isProductDetailsRestricted) { array_map(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
return parseProduct($product, $isProductDetailsRestricted); return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
}, $products) }, $products)
); );
} }
// Log the incident // Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products'); (new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set // 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 // 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 // Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) { $departmentSpecificProductIds = array_map(function ($product) {
return $product->id; return $product->id;
}, $departmentSpecificProducts); }, $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 // Return the list of products
$response->success( $response->success(
(new products_o())->applyDepartmentPricing((array)(new products_o())->listObjectsWithPaginationIfSet( (new products_o())->applyDepartmentPricing($products, $departmentId)
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'])
); );
} }
// Return the list of products // Return the list of products
$response->success( $products = (new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
(new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted) { return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
return parseProduct($product, $isProductDetailsRestricted); });
}) if ($restrictCustomerBookingProducts) {
); $products = $this->filterProductsVisibleOnBookingForm((array)$products);
}
$response->success($products);
} else { } else {
// Log the incident // Log the incident
(new logs_o())->add('products', 'global', 1, 0, 'LIST_PRODUCTS', 'No user found, or invalid session'); (new logs_o())->add('products', 'global', 1, 0, 'LIST_PRODUCTS', 'No user found, or invalid session');
@@ -329,7 +430,7 @@ class productsRoute
} }
}, },
[ [
'list_products' => 'List all products' 'list_products' => 'List all products. Authenticated customer booking sessions may read booking-visible products without the permission.'
] ]
); );
+21 -1
View File
@@ -3,6 +3,7 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o; use objects\groups_o;
use objects\logs_o; use objects\logs_o;
use traits\route_t; 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 () { self::post('/roles/permissions', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
@@ -182,4 +202,4 @@ class rolesRoute
] ]
); );
} }
} }
+522 -35
View File
@@ -6,6 +6,7 @@ use classes\authentication;
use classes\economic; use classes\economic;
use classes\gatewayapi; use classes\gatewayapi;
use classes\response; use classes\response;
use classes\subuser_permission_templates_service;
use classes\virkdata; use classes\virkdata;
use Exception; use Exception;
use modules\virkdata\helpers\virkdata_response; use modules\virkdata\helpers\virkdata_response;
@@ -120,6 +121,27 @@ class subusersRoute
return array_values(array_unique($permissions)); 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 private function normalizeOptionalString(mixed $value): ?string
{ {
if ($value === null) { if ($value === null) {
@@ -337,6 +359,7 @@ class subusersRoute
{ {
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : []; $grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
$templateService = new subuser_permission_templates_service();
$setupRequired = $subuser->requiresSetup(); $setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false; $grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired; $inviteAccepted = !$setupRequired;
@@ -370,6 +393,8 @@ class subusersRoute
'grant_note' => $grant ? $grant->note->value() : null, 'grant_note' => $grant ? $grant->note->value() : null,
'grant_permissions' => $grantPermissions, 'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions, 'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'access_state' => $accessState, 'access_state' => $accessState,
]; ];
} }
@@ -429,8 +454,8 @@ class subusersRoute
'id' => 's.`id`', 'id' => 's.`id`',
'created_at' => 's.`created_at`', 'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at', 'updated_at' => 'row_updated_at',
'customer_number' => 'g.`billing_customer_number`', 'customer_number' => 'customer_number_sort',
'grant_id' => 'g.`id`', 'grant_id' => 'grant_id_sort',
'name' => 's.`name`', 'name' => 's.`name`',
]; ];
@@ -462,10 +487,10 @@ class subusersRoute
$statement->bind_param($types, ...$refs); $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); $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)); $grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$accessState = 'inactive'; $accessState = 'inactive';
@@ -475,6 +500,44 @@ class subusersRoute
$accessState = 'disabled'; $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 [ return [
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'username' => $row['username'] ?? null, 'username' => $row['username'] ?? null,
@@ -490,20 +553,178 @@ class subusersRoute
'invite_accepted' => !$setupRequired, 'invite_accepted' => !$setupRequired,
'can_resend_invite' => $setupRequired, 'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false, 'profile_editable_by_manager' => false,
'customer_number' => (int)$row['customer_number'], 'customer_number' => $primaryGrant['customer_number'] ?? null,
'customer_name' => $row['customer_name'] ?: null, 'customer_name' => $primaryGrant['customer_name'] ?? null,
'grant_id' => (int)$row['grant_id'], 'grant_id' => $primaryGrant['grant_id'] ?? null,
'grant_enabled' => $grantEnabled, 'grant_enabled' => $primaryGrant['grant_enabled'] ?? false,
'grant_note' => $row['grant_note'] ?? null, 'grant_note' => $primaryGrant['grant_note'] ?? null,
'grant_permissions' => $grantPermissions, 'grant_permissions' => $primaryGrant['grant_permissions'] ?? [],
'permissions' => $grantPermissions, 'permissions' => $primaryGrant['permissions'] ?? [],
'grant_created_at' => $row['grant_created_at'] ?? null, 'permission_template_key' => $primaryGrant['permission_template_key'] ?? subuser_permission_templates_service::TEMPLATE_DEACTIVATED,
'grant_updated_at' => $row['grant_updated_at'] ?? null, '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, '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; global $db, $response;
@@ -521,6 +742,11 @@ class subusersRoute
if (!$includeNonEnabled) { if (!$includeNonEnabled) {
$where[] = 'g.`enabled` = 1'; $where[] = 'g.`enabled` = 1';
} }
if ($customerNumber !== null) {
$where[] = 'g.`billing_customer_number` = ?';
$params[] = $customerNumber;
$types .= 'i';
}
if ($pagination['search'] !== null) { if ($pagination['search'] !== null) {
$where[] = "( $where[] = "(
@@ -532,7 +758,12 @@ class subusersRoute
OR CAST(s.`phone` AS CHAR) LIKE ? OR CAST(s.`phone` AS CHAR) LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ? OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` 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'] . '%'; $search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) { for ($i = 0; $i < 9; $i++) {
@@ -545,10 +776,9 @@ class subusersRoute
$fromSql = " $fromSql = "
FROM `subuser_grants` g FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser` 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); $countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) { if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error); 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); $total = (int)($countResult->fetch_assoc()['count'] ?? 0);
$countStatement->close(); $countStatement->close();
$dataSql = " $pageSql = "
SELECT SELECT
s.`id`, s.`id`,
s.`username`, s.`username`,
@@ -572,31 +802,100 @@ class subusersRoute
s.`created_at`, s.`created_at`,
s.`updated_at`, s.`updated_at`,
s.`suspended_at`, s.`suspended_at`,
g.`id` AS `grant_id`, MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`,
g.`billing_customer_number` AS `customer_number`, MIN(g.`billing_customer_number`) AS `customer_number_sort`,
g.`enabled` AS `grant_enabled`, MAX(g.`id`) AS `grant_id_sort`
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`
$fromSql $fromSql
$whereSql $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']} ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
"; ";
$dataStatement = $db->conn->prepare($dataSql); $pageStatement = $db->conn->prepare($pageSql);
if ($dataStatement === false) { if ($pageStatement === false) {
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error); throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
} }
$dataParams = [...$params, (int)$pagination['limit'], $offset]; $pageParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams); $this->bindStatementParameters($pageStatement, $types . 'ii', $pageParams);
$dataStatement->execute(); $pageStatement->execute();
$result = $dataStatement->get_result(); $result = $pageStatement->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC); $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( $response->paginate(
(int)$pagination['page'], (int)$pagination['page'],
@@ -607,7 +906,107 @@ class subusersRoute
[$pagination['order_field'] => $pagination['order_direction']] [$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 private function handleInviteSubuserForCustomer(int $customerNumber): void
@@ -617,6 +1016,9 @@ class subusersRoute
if ($customerNumber <= 0) { if ($customerNumber <= 0) {
$response->error('Customer number is required', 400); $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']); self::requireParameters(['name', 'phone_country_code', 'phone']);
@@ -624,6 +1026,7 @@ class subusersRoute
$phoneCountryCode = (int)self::getParameter('phone_country_code'); $phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone'); $phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null; $note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$templateAccess = $this->parseAccessTemplatePayload();
$enabled = true; $enabled = true;
if (self::isParametersSet(['enabled'])) { if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -632,6 +1035,10 @@ class subusersRoute
$permissions = self::isParametersSet(['permissions']) $permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), []) ? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null; : null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
if ($name === null || strlen($name) < 3 || strlen($name) > 255) { if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400); $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::requireType($note, self::type_string());
self::requireMaxLength('note', 65535); self::requireMaxLength('note', 65535);
} }
$templateAccess = $this->parseAccessTemplatePayload();
$permissions = self::isParametersSet(['permissions']) $permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), []) ? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null; : null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
try { try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions); $grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
$response->success(['grant' => $grant->asArray()]); $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 // Update fields provided in the request
if (self::isParametersSet(['enabled'])) { if (self::isParametersSet(['enabled'])) {
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $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.', '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 () { $this->post('/subusers', function () {
global /** @var response $response */ global /** @var response $response */
$response; $response;
@@ -1150,6 +1587,26 @@ class subusersRoute
'list_subusers' => 'List all chauffeur access grants for superusers.', '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 () { $this->get('/subusers', function () {
global $response; global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST); $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).', '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 () { $this->post('/subusers/invite', function () {
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD); $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
$this->handleInviteSubuserForCustomer($customerNumber); $this->handleInviteSubuserForCustomer($customerNumber);
@@ -1306,6 +1771,28 @@ class subusersRoute
'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).', '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 () { $this->post('/subusers/invite/resend', function () {
global $response; global $response;
@@ -3,11 +3,14 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\department_customer_pricing_service;
use classes\limited_backoffice_exception;
use objects\branding_o; use objects\branding_o;
use objects\department_variables_o; use objects\department_variables_o;
use objects\departments_o; use objects\departments_o;
use objects\logs_o; use objects\logs_o;
use objects\products_o; use objects\products_o;
use objects\users_o;
use traits\route_t; use traits\route_t;
class superuserDepartmentRoute class superuserDepartmentRoute
@@ -38,9 +41,9 @@ class superuserDepartmentRoute
// Log the incident // Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT', 'Successfully fetched department'); (new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT', 'Successfully fetched department');
// Return the department // Return the department
$response->success( $department = (new departments_o())->getDepartmentById((int)$this->fromRequest('department_id'));
(new departments_o())->getDepartmentById((int)$this->fromRequest('department_id')) $department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
); $response->success($department);
} else { } else {
// Log the incident // Log the incident
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT', 'No user found, or invalid session'); (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' '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 () { $this->get('/superuser/department/variables', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; 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([ $response->success([
'message' => 'System search cache cleared', 'message' => 'System search cache cleared',
'query_cache_cleared' => true, '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 () { $this->post('/superuser/search/system/cache/rebuild', function () {
@@ -49,15 +48,12 @@ class systemSearchRoute
$types = $this->parseTypeList($params['types'] ?? []); $types = $this->parseTypeList($params['types'] ?? []);
$request = system_search_cache::enqueueRebuild($scope, $types); $request = system_search_cache::enqueueRebuild($scope, $types);
// Rebuild endpoint also clears parser namespace immediately.
system_search_cache::clearQueryCaches(); system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$response->success([ $response->success([
'message' => 'System search cache rebuild queued', 'message' => 'System search cache rebuild queued',
'request' => $request, 'request' => $request,
'query_cache_cleared' => true, 'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]); ]);
}, [ }, [
'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild', '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'] ?? []); $includeTypes = $this->parseTypeList($params['include_types'] ?? []);
$excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []); $excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []);
$includeAssociations = $this->toBool($params['include_associations'] ?? true, true); $includeAssociations = $this->toBool($params['include_associations'] ?? true, true);
$debugIntent = $this->toBool($params['debug_intent'] ?? false, false); $maxResults = $this->clampMaxResults((int)($params['max_results'] ?? 50));
$limit = $this->clampInt((int)($params['limit'] ?? 50), 1, 200, 50);
$offset = max(0, (int)($params['offset'] ?? 0));
[$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes(); [$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes();
if (empty($allowedTypes)) { if (empty($allowedTypes)) {
@@ -118,9 +112,7 @@ class systemSearchRoute
'permissions_catalog_own' => $permissionsCatalogOwn, 'permissions_catalog_own' => $permissionsCatalogOwn,
'module_config_visibility' => $this->buildModuleConfigVisibility(), 'module_config_visibility' => $this->buildModuleConfigVisibility(),
'include_associations' => $includeAssociations, 'include_associations' => $includeAssociations,
'debug_intent' => $debugIntent, 'max_results' => $maxResults,
'limit' => $limit,
'offset' => $offset,
]); ]);
$response->success($result); $response->success($result);
@@ -596,6 +588,11 @@ class systemSearchRoute
return $value; return $value;
} }
private function clampMaxResults(int $value): int
{
return $this->clampInt($value, 1, 50, 50);
}
private function allEntityTypes(): array private function allEntityTypes(): array
{ {
return array_keys($this->entityPermissionMap()); return array_keys($this->entityPermissionMap());
+33 -3
View File
@@ -33,9 +33,11 @@ class userRoute
// Return an error // Return an error
$response->error('User not found', 404); $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 // Return the list of users
$response->success( $response->success(
$targetUser->includeIncludes(['all'])->asArray() $targetUserData
); );
} else { } else {
// Log the incident // Log the incident
@@ -103,6 +105,9 @@ class userRoute
} }
// Check if the required fields are set // Check if the required fields are set
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$response->error('Invalid request body', 400);
}
if (!isset($data['discount'])) { if (!isset($data['discount'])) {
// Log the incident // Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set'); (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); $response->error('No is_category set', 400);
} }
$discount = (int)$data['discount']; $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']; $is_category = (bool)$data['is_category'];
if ($is_category) { if ($is_category) {
$object_id = (string)$data['object_id']; $object_id = (string)$data['object_id'];
} else { } else {
$object_id = (int)$data['object_id']; $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 // 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 { try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion( (new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$targetUser->id, (int)$targetUser->id,
@@ -145,7 +174,8 @@ class userRoute
'route' => '/superuser/user/discounts', 'route' => '/superuser/user/discounts',
'method' => 'POST', 'method' => 'POST',
'actor_user_id' => (int)$user->id, 'actor_user_id' => (int)$user->id,
] ],
$fixed_price
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
(new logs_o())->add( (new logs_o())->add(
+93 -12
View File
@@ -27,19 +27,28 @@ class usersRoute
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users'); (new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
// Return the list of users // Return the list of users
$users_o = new users_o(); $users_o = new users_o();
$response->success( $limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
$users_o->parseUsers( $users = $users_o
$users_o ->setSearchableFields([
->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
// 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',
'id', 'customer_number',
'customer_number', 'group_id',
'group_id', 'display_name',
'display_name', ])
]) ->listObjectsWithPaginationIfSet(
->listObjectsWithPaginationIfSet() null,
) $limitedEmployeeListMode['filters'],
[],
$limitedEmployeeListMode['additional_where']
);
if ($limitedEmployeeListMode['enabled']) {
$users = $users_o->markLimitedBackofficeManagedUsers($users);
}
$users = $users_o->parseUsers(
$users
); );
$response->success($users);
} else { } else {
// Log the incident // Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session'); (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'] === '') { if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
$data['display_name'] = null; $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 the role is set, require the edit_user_role permission
if ($data['role']) { if ($data['role']) {
$this->requirePermission('edit_user_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 . '))',
];
}
} }
+392
View File
@@ -21,6 +21,311 @@ class vehiclesRoute
{ {
use route_t; 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 public function run(): void
{ {
$this->get('/vehicles', function () { $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 () { $this->post('/vehicles', function () {
global $response; global $response;
$auth = new authentication(); $auth = new authentication();
@@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` (
`is_category` TINYINT(1) NOT NULL, `is_category` TINYINT(1) NOT NULL,
`object_id` VARCHAR(64) NOT NULL, `object_id` VARCHAR(64) NOT NULL,
`discount` INT NOT NULL, `discount` INT NOT NULL,
`fixed_price` INT NULL DEFAULT NULL,
`effective_from` DATETIME NOT NULL, `effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL, `effective_to` DATETIME NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test', `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']);
});
@@ -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']); ->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 { it('creates departments through the real endpoint', function (): void {
api_test_covers('POST /departments', 'happy'); api_test_covers('POST /departments', 'happy');
@@ -163,6 +163,22 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
->toHaveKey('session_type', 'gateway-stream') ->toHaveKey('session_type', 'gateway-stream')
->toHaveKey('gateway_id', (int)$gateway['id']); ->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( $shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions', '/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
['reason' => 'Broker shell validation'], ['reason' => 'Broker shell validation'],
File diff suppressed because it is too large Load Diff
@@ -37,6 +37,18 @@ function order_booking_create_department(string $name): array
]); ]);
} }
function order_booking_create_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
}
it('lets customers create their own order bookings without booking permissions', function (): void { it('lets customers create their own order bookings without booking permissions', function (): void {
api_test_covers('POST /order-bookings', 'auth'); api_test_covers('POST /order-bookings', 'auth');
@@ -65,7 +77,51 @@ it('lets customers create their own order bookings without booking permissions',
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
}); });
it('lets subusers create own customer order bookings without the bookings add node', function (): void { 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'); api_test_covers('POST /order-bookings', 'auth');
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']); $customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']);
@@ -79,6 +135,27 @@ it('lets subusers create own customer order bookings without the bookings add no
$session['headers'] $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 $response
->assertStatus(200) ->assertStatus(200)
->assertEnvelope() ->assertEnvelope()
@@ -115,6 +115,67 @@ it('requires notes when adding the extraordinary chemistry product to an order',
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side'); 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 { it('only allows tankcleaning products for only tankcleaning customers', function (): void {
api_test_covers('POST /order/items', 'customer_rules'); api_test_covers('POST /order/items', 'customer_rules');
@@ -207,7 +268,8 @@ it('does not allow clearing notes for order items whose product requires notes',
'reference' => 'NOTE-EDIT', 'reference' => 'NOTE-EDIT',
]); ]);
$product = api_fixtures()->createProduct([ $product = api_fixtures()->createProduct([
'name' => 'API Note Required Product', 'id' => 902702,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 199, 'price' => 199,
'requires_note' => 1, 'requires_note' => 1,
]); ]);
@@ -219,7 +281,7 @@ it('does not allow clearing notes for order items whose product requires notes',
'quantity' => 1, 'quantity' => 1,
'notes' => 'Initial note', 'notes' => 'Initial note',
]); ]);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']); $session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client() api_client()
->put('/order/items', [ ->put('/order/items', [
@@ -73,7 +73,7 @@ it('creates orders through the orders endpoint', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']); $customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Order Create Department']); $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', [ $response = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'], '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', 'po' => 'DELETED-BOOKING-PO',
'deleted_at' => date('Y-m-d H:i:s'), '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'], '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); $matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO'); 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'], 'customer_number' => $otherCustomer['customer_number'],
]); ]);
$unauthorizedResponse = api_client()->post('/orders', [ $unauthorizedResponse = api_client()->post('/orders', [
@@ -233,7 +233,7 @@ it('rejects invalid order creation requests', function (): void {
$customer = api_fixtures()->createUser(); $customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment(); $department = api_fixtures()->createDepartment();
$session = api_fixtures()->createUserSession(['add_order']); $session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
api_client()->post('/orders', [ api_client()->post('/orders', [
'customer_id' => $customer['customer_number'], 'customer_id' => $customer['customer_number'],
@@ -262,7 +262,7 @@ it('updates orders through the primary and legacy endpoints', function (): void
'notes' => 'Before update', 'notes' => 'Before update',
'reg_1' => 'BEFORE1', 'reg_1' => 'BEFORE1',
]); ]);
$session = api_fixtures()->createUserSession(['edit_order']); $session = api_fixtures()->createUserSession(['edit_order', 'department_access_' . $department['id']]);
api_client()->put('/orders', [ api_client()->put('/orders', [
'id' => $order['id'], 'id' => $order['id'],
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
usesApiSuite();
function products_api_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
it('treats null-like optional product params as omitted for product detail requests', function (): void {
api_test_covers('GET /products', 'optional-params');
$product = api_fixtures()->createProduct([
'name' => 'Null Query Product',
'price' => 400,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->get(
'/products?id=' . (int)$product['id']
. '&department_id=null&customer_id=null&category_id=null&final_price=false',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id']);
expect($response->body)->not->toContain('department_access_0');
});
it('returns final department pricing without requiring list_products permission', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Department Priced Product',
'price' => 500,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 375);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id'])
->toHaveKey('price', 375);
expect($response->body)->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions read own single-product final pricing with customer context', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Single Customer Product Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Single Customer Priced Product',
'price' => 0,
'display_in_booking_form' => 0,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 175);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id']
. '&customer_id=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('id', (int)$product['id'])
->toHaveKey('price', 175);
expect($response->body)->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions list public products with final department pricing', function (): void {
api_test_covers('GET /products', 'customer-booking');
$department = api_fixtures()->createDepartment(['name' => 'Customer Booking Products Department']);
$product = api_fixtures()->createProduct([
'name' => 'Customer Booking Visible Wash',
'price' => 700,
'is_wash' => 1,
'display_in_booking_form' => 1,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 650);
$hiddenProduct = api_fixtures()->createProduct([
'name' => 'Customer Booking Hidden Wash',
'price' => 900,
'is_wash' => 1,
'display_in_booking_form' => 0,
]);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?department_id=' . (int)$department['id'] . '&final_price=true',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$productsById = [];
foreach ($response->data() as $returnedProduct) {
$productsById[(int)($returnedProduct['id'] ?? 0)] = $returnedProduct;
}
expect($productsById)
->toHaveKey((int)$product['id'])
->and($productsById[(int)$product['id']]['name'] ?? null)->toBe('Customer Booking Visible Wash')
->and((int)($productsById[(int)$product['id']]['price'] ?? 0))->toBe(650)
->and($productsById[(int)$product['id']]['display_in_booking_form'] ?? null)->toBeTrue();
expect($productsById)->not->toHaveKey((int)$hiddenProduct['id']);
expect($response->body)
->not->toContain('list_products')
->not->toContain('department_access_' . (int)$department['id']);
});
it('lets customer booking sessions with list_products read own final department pricing without department access', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$department = api_fixtures()->createDepartment(['name' => 'Own Customer Booking Pricing Department']);
$product = api_fixtures()->createProduct([
'name' => 'Own Customer Booking Wash',
'price' => 800,
'is_wash' => 1,
'display_in_booking_form' => 1,
]);
products_api_department_price((int)$department['id'], (int)$product['id'], 725);
$session = api_fixtures()->createUserSession(['user', 'list_products']);
$response = api_client()->get(
'/products?department_id=' . (int)$department['id']
. '&final_price=true'
. '&customer_id=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$productsById = [];
foreach ($response->data() as $returnedProduct) {
$productsById[(int)($returnedProduct['id'] ?? 0)] = $returnedProduct;
}
expect($productsById)
->toHaveKey((int)$product['id'])
->and((int)($productsById[(int)$product['id']]['price'] ?? 0))->toBe(725);
expect($response->body)
->not->toContain('department_access_' . (int)$department['id']);
});
it('still requires department access when list_products users request another customer final department pricing', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$department = api_fixtures()->createDepartment(['name' => 'Other Customer Pricing Department']);
$otherCustomer = api_fixtures()->createUser();
$session = api_fixtures()->createUserSession(['user', 'list_products']);
api_client()->get(
'/products?department_id=' . (int)$department['id']
. '&final_price=true'
. '&customer_id=' . (int)$otherCustomer['customer_number'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
it('prevents customer booking sessions from requesting another customer product pricing', function (): void {
api_test_covers('GET /products', 'customer-booking-auth');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser();
api_client()->get(
'/products?final_price=true&customer_id=' . (int)$otherCustomer['customer_number'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['list_products']);
});
it('rejects invalid department ids without requesting department access zero', function (): void {
api_test_covers('GET /products', 'validation');
$product = api_fixtures()->createProduct([
'name' => 'Invalid Department Product',
'price' => 600,
]);
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid department_id');
expect($response->body)->not->toContain('department_access_0');
});
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lists limited backoffice permission templates for superuser role maintenance', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'happy');
$session = api_fixtures()->createUserSession([
'superuser',
'add_role_permission',
]);
$response = api_client()->get('/roles/limited-backoffice-permission-templates', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$templates = $response->data();
expect(array_column($templates, 'key'))->toBe([
'viewer',
'cashier',
'booking_coordinator',
'operations_lead',
'department_admin',
]);
$templatesByKey = array_column($templates, null, 'key');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_department_daily_reports');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_notifications');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('statistics_orders_new');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_access');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_prices_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_customer_pricing_view');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_customer_pricing_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_employees_manage');
});
it('requires superuser and role permission edit access for limited backoffice permission templates', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'auth');
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['add_role_permission'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser']);
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['superuser'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_role_permission']);
});
@@ -112,6 +112,32 @@ it('requires subuser management access before exposing permission nodes', functi
expect($authorized->data())->toBeArray()->not->toBeEmpty(); expect($authorized->data())->toBeArray()->not->toBeEmpty();
}); });
it('requires subuser management access before exposing simplified permission templates', function (): void {
api_test_covers('GET /subusers/permission-templates', 'auth');
api_test_covers('GET /subusers/permission-templates', 'happy');
$unauthenticated = api_client()->get('/subusers/permission-templates');
$unauthenticated
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false);
$session = api_fixtures()->createUserSession(['list_own_subusers']);
$authorized = api_client()->get('/subusers/permission-templates', $session['headers']);
$authorized
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($authorized->data()['templates'] ?? null)->toBeArray()->not->toBeEmpty();
expect(array_column($authorized->data()['templates'], 'key'))
->toContain('driver')
->toContain('booking_coordinator')
->toContain('fleet_admin')
->toContain('deactivated');
expect($authorized->data()['groups'] ?? null)->toBeArray()->not->toBeEmpty();
});
it('rejects customer subuser listing without own-scope permission', function (): void { it('rejects customer subuser listing without own-scope permission', function (): void {
api_test_covers('GET /subusers', 'auth'); api_test_covers('GET /subusers', 'auth');
@@ -202,6 +228,11 @@ it('lists chauffeur grants across customers for superusers', function (): void {
(int)$secondCustomer['customer_number'], (int)$secondCustomer['customer_number'],
['BOOKINGS_LIST'] ['BOOKINGS_LIST']
); );
$sharedGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$secondCustomer['customer_number'],
['ORDERS_LIST']
);
$response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']); $response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']);
@@ -213,22 +244,26 @@ it('lists chauffeur grants across customers for superusers', function (): void {
$rows = array_values(array_filter( $rows = array_values(array_filter(
is_array($response->data()) ? $response->data() : [], is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item) static fn (mixed $item): bool => is_array($item)
&& in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true) && in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true)
)); ));
expect($rows)->toHaveCount(2); expect($rows)->toHaveCount(2);
$byGrantId = []; $bySubuserId = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$byGrantId[(int)$row['grant_id']] = $row; $bySubuserId[(int)$row['id']] = $row;
} }
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']); expect($bySubuserId[(int)$firstSubuser['id']]['grant_count'])->toBe(2);
expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha'); expect(array_column($bySubuserId[(int)$firstSubuser['id']]['grants'], 'grant_id'))
expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']); ->toContain($firstGrantId)
expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']); ->toContain($sharedGrantId);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta'); expect($bySubuserId[(int)$firstSubuser['id']]['customer_numbers'])
expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']); ->toContain((int)$firstCustomer['customer_number'])
->toContain((int)$secondCustomer['customer_number']);
expect($bySubuserId[(int)$secondSubuser['id']]['grant_count'])->toBe(1);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['grant_id'])->toBe($secondGrantId);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['customer_name'])->toBe('Fleet Customer Beta');
}); });
it('lets superusers invite chauffeurs for a selected customer', function (): void { it('lets superusers invite chauffeurs for a selected customer', function (): void {
@@ -280,3 +315,275 @@ it('lets superusers invite chauffeurs for a selected customer', function (): voi
} }
} }
}); });
it('applies simplified driver access templates when inviting and updating chauffeur grants', function (): void {
api_test_covers('POST /superuser/subusers/invite', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'failure');
$session = api_fixtures()->createUserSession(['add_subusers', 'manage_subuser_grants']);
$customer = api_fixtures()->createUser(['display_name' => 'Template Target Customer']);
$phone = 72000000 + ((int)$customer['customer_number'] % 1000000);
$createdSubuserId = null;
$createdGrantId = null;
$setupToken = null;
try {
$invite = api_client()->post('/superuser/subusers/invite', [
'customer_number' => (int)$customer['customer_number'],
'name' => 'Template Driver',
'phone_country_code' => 45,
'phone' => $phone,
'permission_template_key' => 'booking_coordinator',
], $session['headers']);
$invite
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $invite->data();
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
expect($payload['subuser']['permission_template_key'] ?? null)->toBe('booking_coordinator');
expect($payload['subuser']['grant_permissions'] ?? null)
->toContain('BOOKINGS_EDIT')
->toContain('BOOKINGS_ADD')
->not->toContain('SUBUSERS_ADD');
$deactivate = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'deactivated'],
$session['headers']
);
$deactivate
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($deactivate->data()['subuser']['permission_template_key'] ?? null)->toBe('deactivated');
expect($deactivate->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($deactivate->data()['grant']['permissions'] ?? null)->toBe([]);
$invalid = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'unknown_template'],
$session['headers']
);
$invalid
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
} finally {
if ($setupToken !== null && $setupToken !== '') {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
if ($createdGrantId !== null) {
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
}
if ($createdSubuserId !== null) {
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
}
}
});
it('lists and summarizes chauffeurs through the user-scoped superuser route', function (): void {
$session = api_fixtures()->createUserSession(['list_subusers']);
$targetCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Alpha',
'economic_customer_name' => 'Scoped Customer Alpha',
]);
$otherCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Beta',
'economic_customer_name' => 'Scoped Customer Beta',
]);
$activeSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Active Driver']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Scoped Pending Driver',
'password_plaintext' => null,
]);
$disabledSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Disabled Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Other Driver']);
$activeGrantId = api_fixtures()->grantSubuser(
(int)$activeSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['BOOKINGS_LIST']
);
$disabledGrantId = api_fixtures()->grantSubuser(
(int)$disabledSubuser['id'],
(int)$targetCustomer['customer_number'],
['ORDERS_LIST']
);
api_test_runtime()->db()->query('UPDATE `subuser_grants` SET `enabled` = 0 WHERE `id` = ' . $disabledGrantId);
api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['SELFSERVE_LIST']
);
$response = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers?page=1&limit=20&include_non_enabled=true',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = is_array($response->data()) ? $response->data() : [];
$grantIds = array_map(static fn (array $row): int => (int)($row['grant_id'] ?? 0), $rows);
expect($grantIds)->toContain($activeGrantId);
expect($grantIds)->not->toContain(0);
expect($rows)->toHaveCount(3);
foreach ($rows as $row) {
expect($row['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number']);
}
expect($response->meta()['user_context']['user_id'] ?? null)->toBe((int)$targetCustomer['id']);
expect($response->meta()['subusers_summary'] ?? null)->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
expect($summary->data())->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
});
it('edits only customer-matching chauffeur grants through the user-scoped route', function (): void {
$session = api_fixtures()->createUserSession(['manage_subuser_grants']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']);
$targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']);
$targetGrantId = api_fixtures()->grantSubuser(
(int)$targetSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$otherGrantId = api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['BOOKINGS_LIST']
);
$update = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId,
[
'enabled' => false,
'note' => 'Scoped note',
'permissions' => ['ORDERS_LIST'],
],
$session['headers']
);
$crossCustomer = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId,
['note' => 'Should not save'],
$session['headers']
);
$update
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$crossCustomer
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
expect($update->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note');
expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']);
});
it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void {
$session = api_fixtures()->createUserSession(['add_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Other']);
$response = api_client()->post('/superuser/users/' . $targetCustomer['id'] . '/subusers/invite', [
'customer_number' => (int)$otherCustomer['customer_number'],
'name' => 'Mismatched Driver',
'phone_country_code' => 45,
'phone' => 71999999,
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Customer number does not match selected user');
});
it('resends pending user-scoped chauffeur invites and blocks accepted accounts', function (): void {
$session = api_fixtures()->createUserSession(['edit_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Resend Customer']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Pending Resend Driver',
'password_plaintext' => null,
]);
$acceptedSubuser = api_fixtures()->createSubuser(['name' => 'Accepted Resend Driver']);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$acceptedSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$pending = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $pendingSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$accepted = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $acceptedSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$pending
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$accepted
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
$token = $pending->data()['invite']['setup_token'] ?? null;
expect($token)->toBeString();
(new \objects\subusers_o())->invalidateSetupToken((string)$token);
});
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('sets preserves and clears product fixed prices through the user discounts endpoint', function (): void {
api_test_covers('POST /superuser/user/discounts', 'pricing');
api_test_covers('GET /superuser/user/discounts', 'pricing');
$customer = api_fixtures()->createUser(['display_name' => 'Endpoint Fixed Price Customer']);
$product = api_fixtures()->createProduct([
'name' => 'Endpoint Fixed Price Product',
'price' => 900,
]);
$session = api_fixtures()->createUserSession([
'set_custom_price',
'get_custom_prices_other',
]);
$findProductRow = function () use ($customer, $product, $session): array {
$response = api_client()->get(
'/superuser/user/discounts?user_id=' . $customer['id'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
foreach ($response->data() as $row) {
if ((int)($row['product_or_category_id'] ?? 0) === (int)$product['id'] && !($row['is_category'] ?? false)) {
return $row;
}
}
throw new RuntimeException('Expected product override row was not returned.');
};
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 20,
'fixed_price' => 350,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(20);
expect((int)$row['fixed_price'])->toBe(350);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(10);
expect((int)$row['fixed_price'])->toBe(350);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
'fixed_price' => null,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$row = $findProductRow();
expect((int)$row['percentage'])->toBe(10);
expect($row['fixed_price'])->toBeNull();
});
it('rejects invalid fixed price payloads for user discounts', function (): void {
api_test_covers('POST /superuser/user/discounts', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Invalid Fixed Price Customer']);
$product = api_fixtures()->createProduct(['name' => 'Invalid Fixed Price Product']);
$category = api_fixtures()->createCategory(['name' => 'Invalid Fixed Price Category']);
$session = api_fixtures()->createUserSession(['set_custom_price']);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => $product['id'],
'is_category' => false,
'discount' => 10,
'fixed_price' => '12.5',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
api_client()
->post('/superuser/user/discounts', [
'user_id' => $customer['id'],
'object_id' => (string)$category['id'],
'is_category' => true,
'discount' => 10,
'fixed_price' => 350,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -4,6 +4,28 @@ declare(strict_types=1);
usesApiSuite(); usesApiSuite();
function vehicles_without_customer_vehicles_deleted_at(callable $callback): void
{
$db = api_test_runtime()->db();
$column = $db->query("SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'");
if ($column === false) {
throw new RuntimeException('Unable to inspect customer_vehicles.deleted_at test column.');
}
$hadColumn = (int)$column->num_rows > 0;
if ($hadColumn) {
$db->query('ALTER TABLE `customer_vehicles` DROP COLUMN `deleted_at`');
}
try {
$callback();
} finally {
if ($hadColumn) {
$db->query('ALTER TABLE `customer_vehicles` ADD COLUMN `deleted_at` DATETIME NULL');
}
}
}
it('defaults wash subscriptions to false when a customer creates a vehicle without the field', function (): void { it('defaults wash subscriptions to false when a customer creates a vehicle without the field', function (): void {
api_test_covers('POST /vehicles', 'happy'); api_test_covers('POST /vehicles', 'happy');
@@ -194,3 +216,249 @@ it('returns a null vehicle last_order_id when no order with items exists', funct
'last_order_id' => null, 'last_order_id' => null,
]); ]);
}); });
it('lists and summarizes vehicles through the user-scoped superuser route', function (): void {
api_test_covers('GET /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('GET /superuser/users/{user_id}/vehicles/summary', 'happy');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Other Vehicle Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDV1',
'wash_subscription' => 1,
'reference' => 'Scoped fleet',
]);
api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'OTHERV1',
'wash_subscription' => 1,
]);
$session = api_fixtures()->createUserSession(['list_vehicles_other']);
$response = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles?page=1&limit=20',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = $response->data();
expect(array_column($rows, 'reg'))->toContain('SCOPEDV1');
expect(array_column($rows, 'reg'))->not->toContain('OTHERV1');
expect($rows[0])->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDV1',
]);
expect($response->meta()['user_context'])->toMatchArray([
'user_id' => $targetUser['id'],
'customer_number' => $targetUser['customer_number'],
]);
expect($summary->data())->toMatchArray([
'total' => 1,
'wash_subscription' => 1,
'self_service' => 0,
]);
});
it('lists and summarizes user-scoped superuser vehicles when customer vehicles has no deleted at column', function (): void {
vehicles_without_customer_vehicles_deleted_at(function (): void {
api_test_covers('GET /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('GET /superuser/users/{user_id}/vehicles/summary', 'happy');
$customerVehiclesDeletedAtColumn = api_test_runtime()->db()->query(
"SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'"
);
expect($customerVehiclesDeletedAtColumn)->not->toBeFalse();
expect((int)$customerVehiclesDeletedAtColumn->num_rows)->toBe(0);
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Legacy Schema Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Other Vehicle Legacy Schema Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'LEGACYV1',
'wash_subscription' => 1,
'reference' => 'Legacy schema fleet',
]);
api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'LEGACYOTHER',
'wash_subscription' => 1,
]);
$session = api_fixtures()->createUserSession(['list_vehicles_other']);
$response = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles?page=1&limit=20',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetUser['id'] . '/vehicles/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = $response->data();
expect(array_column($rows, 'reg'))->toContain('LEGACYV1');
expect(array_column($rows, 'reg'))->not->toContain('LEGACYOTHER');
expect($rows[0])->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'LEGACYV1',
]);
expect($response->meta()['user_context'])->toMatchArray([
'user_id' => $targetUser['id'],
'customer_number' => $targetUser['customer_number'],
]);
expect($summary->data())->toMatchArray([
'total' => 1,
'wash_subscription' => 1,
'self_service' => 0,
]);
});
});
it('creates vehicles through the user-scoped superuser route and rejects mismatched customer ids', function (): void {
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'failure');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Create Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Mismatch Customer']);
$session = api_fixtures()->createUserSession(['add_vehicle_other']);
$created = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'type' => 53,
'reg' => 'SCOPEDADD',
'wash_subscription' => true,
'reference' => 'Created from user detail',
], $session['headers']);
$mismatch = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'BADSCOPED',
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$mismatch
->assertStatus(400)
->assertEnvelope()
->assertError();
$vehicleId = (int)($created->data()['id'] ?? 0);
expect($vehicleId)->toBeGreaterThan(0);
expect($created->data())->toMatchArray([
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDADD',
'wash_subscription' => true,
'reference' => 'Created from user detail',
]);
api_fixtures()->cleanupDeleteById('customer_vehicles', $vehicleId);
api_fixtures()->cleanupDeleteWhere('customer_vehicle_subscription_versions', ['vehicle_id' => $vehicleId]);
});
it('edits and deletes only matching customer vehicles through the user-scoped superuser route', function (): void {
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'failure');
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'happy');
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'failure');
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Edit Customer']);
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Guard Customer']);
$vehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDIT',
'wash_subscription' => 0,
]);
$otherVehicle = api_fixtures()->createVehicle([
'customer_id' => $otherUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEBAD',
'wash_subscription' => 0,
]);
$deletableVehicle = api_fixtures()->createVehicle([
'customer_id' => $targetUser['customer_number'],
'type' => 53,
'reg' => 'SCOPEDEL',
'wash_subscription' => 0,
]);
$session = api_fixtures()->createUserSession(['edit_vehicle_other', 'delete_vehicle_other']);
$edited = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $vehicle['id'],
'reg' => 'SCOPEDOK',
'reference' => 'Updated reference',
], $session['headers']);
$moved = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $vehicle['id'],
'customer_id' => $otherUser['customer_number'],
], $session['headers']);
$foreignEdit = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
'id' => $otherVehicle['id'],
'reg' => 'SHOULDFAIL',
], $session['headers']);
$deleted = api_client()->delete(
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $deletableVehicle['id'],
null,
$session['headers']
);
$foreignDelete = api_client()->delete(
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $otherVehicle['id'],
null,
$session['headers']
);
$edited
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$moved
->assertStatus(400)
->assertEnvelope()
->assertError();
$foreignEdit
->assertStatus(404)
->assertEnvelope()
->assertError();
$deleted
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$foreignDelete
->assertStatus(404)
->assertEnvelope()
->assertError();
expect($edited->data())->toMatchArray([
'id' => $vehicle['id'],
'customer_id' => $targetUser['customer_number'],
'reg' => 'SCOPEDOK',
'reference' => 'Updated reference',
]);
});
@@ -0,0 +1,267 @@
<?php
use classes\db;
use classes\department_wash_count_service;
function department_wash_count_integration_db(): db
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.');
}
$host = getenv('CONFIG_DB_HOST') ?: null;
$user = getenv('CONFIG_DB_USER') ?: null;
$password = getenv('CONFIG_DB_PASSWORD') ?: '';
$database = getenv('CONFIG_DB_DATABASE') ?: null;
if (!$host || !$user || !$database) {
test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.');
}
app_require('classes/db.php');
app_require('classes/department_wash_count_service.php');
$db = new db([
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => (int)(getenv('CONFIG_DB_PORT') ?: 3306),
]);
$db->connect();
$GLOBALS['db'] = $db;
department_wash_count_prepare_tables($db);
return $db;
}
function department_wash_count_prepare_tables(db $db): void
{
$db->query(
'CREATE TABLE IF NOT EXISTS department_lanes (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL,
name VARCHAR(255) NULL,
dynamic_image_id INT NULL,
selfserve_enabled TINYINT(1) NOT NULL DEFAULT 0
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS department_selfserve_conditions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL,
name VARCHAR(255) NULL,
product INT NULL,
action VARCHAR(64) NULL,
order_index INT NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS department_selfserve_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
condition_id INT NULL,
product INT NULL,
description TEXT NULL,
task VARCHAR(64) NULL,
buttons TEXT NULL,
order_index INT NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
price INT NOT NULL DEFAULT 0,
is_wash TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
cashier_id INT NOT NULL,
reference VARCHAR(255) NULL,
notes TEXT NULL,
department_id INT NOT NULL,
reg_1 VARCHAR(64) NULL,
reg_2 VARCHAR(64) NULL,
reg_3 VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
include_in_invoice TINYINT(1) NULL,
wash_id VARCHAR(255) NULL,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS order_items (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT NOT NULL,
product_id INT NOT NULL,
reference VARCHAR(255) NULL,
notes TEXT NULL,
cashier_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
quantity INT NOT NULL DEFAULT 1,
deleted_at DATETIME NULL
)'
);
$db->query(
'CREATE TABLE IF NOT EXISTS selfserve_wash_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
lane_id INT NOT NULL,
department_id INT NOT NULL,
reg VARCHAR(64) NOT NULL,
status VARCHAR(64) NOT NULL,
allowed TINYINT(1) NOT NULL DEFAULT 0,
wash_started_at DATETIME NULL,
machine_start_triggered_at DATETIME NULL,
order_id INT NULL,
completed_at DATETIME NULL,
created_at DATETIME NULL,
deleted_at DATETIME NULL
)'
);
}
function department_wash_count_insert_product(db $db, string $suffix, bool $isWash): int
{
$productNameColumn = null;
if ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'name'")) > 0) {
$productNameColumn = 'name';
} elseif ($db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'title'")) > 0) {
$productNameColumn = 'title';
} else {
test()->markTestSkipped('Products table is missing both name and title columns required by this integration test.');
}
$escapedName = $db->escape_string('Wash count product ' . $suffix . ' ' . ($isWash ? 'wash' : 'minute'));
$descriptionSql = $db->num_rows($db->query("SHOW COLUMNS FROM products LIKE 'description'")) > 0
? ', description'
: '';
$descriptionValueSql = $descriptionSql !== '' ? ", 'Integration wash count product'" : '';
$db->query(
"INSERT INTO products ($productNameColumn$descriptionSql, is_wash)
VALUES ('$escapedName'$descriptionValueSql, " . ($isWash ? '1' : '0') . ')'
);
return (int)$db->insert_id();
}
function department_wash_count_insert_order(db $db, int $departmentId, int $productId, string $reference, string $createdAt, int $price = 100): int
{
$escapedReference = $db->escape_string($reference);
$escapedCreatedAt = $db->escape_string($createdAt);
$db->query(
"INSERT INTO orders (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, created_at)
VALUES (1, 1, '$escapedReference', 'Integration test', $departmentId, 'COUNT$departmentId', '', '', '$escapedCreatedAt')"
);
$orderId = (int)$db->insert_id();
$db->query(
"INSERT INTO order_items (order_id, product_id, reference, notes, cashier_id, price, quantity)
VALUES ($orderId, $productId, '$escapedReference', 'Integration test', 1, $price, 1)"
);
return $orderId;
}
function department_wash_count_insert_session(
db $db,
int $departmentId,
string $reg,
string $status,
?string $completedAt,
?int $orderId = null
): int {
$escapedReg = $db->escape_string($reg);
$escapedStatus = $db->escape_string($status);
$completedAtSql = $completedAt === null ? 'NULL' : "'" . $db->escape_string($completedAt) . "'";
$orderIdSql = $orderId === null ? 'NULL' : (string)$orderId;
$db->query(
"INSERT INTO selfserve_wash_sessions (lane_id, department_id, reg, status, allowed, wash_started_at, machine_start_triggered_at, order_id, completed_at, created_at)
VALUES (1, $departmentId, '$escapedReg', '$escapedStatus', 1, NULL, NULL, $orderIdSql, $completedAtSql, $completedAtSql)"
);
return (int)$db->insert_id();
}
it('counts completed self-serve minute billing sessions site-wide and dedupes linked wash orders', function (): void {
$db = department_wash_count_integration_db();
$service = new department_wash_count_service();
$suffix = (string)random_int(10000, 99999);
$departmentId = 700000 + (int)$suffix;
$orderIds = [];
$productIds = [];
$sessionIds = [];
try {
$washProductId = department_wash_count_insert_product($db, $suffix, true);
$minuteProductId = department_wash_count_insert_product($db, $suffix, false);
$productIds = [$washProductId, $minuteProductId];
$orderIds[] = department_wash_count_insert_order($db, $departmentId, $washProductId, 'plain-wash-' . $suffix, '2026-04-01 10:15:00', 100);
$linkedMinuteOrderId = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'linked-minute-' . $suffix, '2026-04-01 11:10:00', 25);
$orderIds[] = $linkedMinuteOrderId;
$linkedWashOrderId = department_wash_count_insert_order($db, $departmentId, $washProductId, 'linked-wash-' . $suffix, '2026-04-01 13:05:00', 100);
$orderIds[] = $linkedWashOrderId;
$orderIds[] = department_wash_count_insert_order($db, $departmentId, $minuteProductId, 'plain-minute-' . $suffix, '2026-04-01 14:00:00', 25);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKMIN' . $suffix, 'COMPLETED', '2026-04-01 12:00:00', $linkedMinuteOrderId);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'STAND' . $suffix, 'COMPLETED', '2026-04-01 12:20:00');
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'LINKWASH' . $suffix, 'COMPLETED', '2026-04-01 13:30:00', $linkedWashOrderId);
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'FORCE' . $suffix, 'FORCE_STOPPED', '2026-04-01 15:00:00');
$sessionIds[] = department_wash_count_insert_session($db, $departmentId, 'OPEN' . $suffix, 'COMPLETED', null);
expect($service->countInDateRange('2026-04-01 00:00:00', '2026-04-01 23:59:59', $departmentId))->toBe(4);
$hourRows = $service->countByHourForDepartments('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]);
$countsByHour = [];
foreach ($hourRows as $row) {
$countsByHour[$row['hour_bucket']] = $row['wash_count'];
}
expect($countsByHour)->toMatchArray([
'2026-04-01 10:00:00' => 1,
'2026-04-01 11:00:00' => 1,
'2026-04-01 12:00:00' => 1,
'2026-04-01 13:00:00' => 1,
]);
expect($countsByHour)->not->toHaveKey('2026-04-01 14:00:00')
->and($countsByHour)->not->toHaveKey('2026-04-01 15:00:00');
$summary = $service->transactionSummary('2026-04-01 00:00:00', '2026-04-01 23:59:59', [$departmentId]);
expect($summary)->toMatchArray([
'quantity' => 4,
'products' => 4,
'earnings' => 250,
'washes' => 4,
]);
} finally {
if ($sessionIds !== []) {
$db->query('DELETE FROM selfserve_wash_sessions WHERE id IN (' . implode(',', array_map('intval', $sessionIds)) . ')');
}
if ($orderIds !== []) {
$orderIdsSql = implode(',', array_map('intval', $orderIds));
$db->query("DELETE FROM order_items WHERE order_id IN ($orderIdsSql)");
$db->query("DELETE FROM orders WHERE id IN ($orderIdsSql)");
}
if ($productIds !== []) {
$db->query('DELETE FROM products WHERE id IN (' . implode(',', array_map('intval', $productIds)) . ')');
}
$db->close();
}
});
@@ -358,6 +358,22 @@ it('persists gateway cutover relay bindings used by self-serve Shelly dispatch',
->toHaveKey('fallback_mode', edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL) ->toHaveKey('fallback_mode', edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL)
->and($context['manager']->getDepartmentTransportMode((int)$department['id'])) ->and($context['manager']->getDepartmentTransportMode((int)$department['id']))
->toBe(edge_gateway_manager::TRANSPORT_MODE_GATEWAY); ->toBe(edge_gateway_manager::TRANSPORT_MODE_GATEWAY);
$context['manager']->setRelayBindings($gatewayId, [], (int)$user['id']);
$recreatedBindings = $context['manager']->setRelayBindings($gatewayId, [[
'relay_id' => 'relay-machine',
'device_id' => 'device-machine-recreated',
'local_ip' => '10.50.60.71',
'channel' => 1,
]], (int)$user['id']);
expect($recreatedBindings)->toHaveCount(1)
->and($recreatedBindings[0])->toMatchArray([
'relay_id' => 'relay-machine',
'device_id' => 'device-machine-recreated',
'local_ip' => '10.50.60.71',
'channel' => 1,
]);
} finally { } finally {
$context['cleanup']->run(); $context['cleanup']->run();
} }

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