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 2ae1fc3fcf Allow customer order booking creation without booking permission 2026-07-06 16:33:31 +02:00
Jeppe Bundgaard d9eacf6f84 Deduplicate limited backoffice price products 2026-07-06 16:28:08 +02:00
Jeppe B f262047476 Merge pull request #300 from copenhagentruckwash/codex/scoped-monthly-split-api
Scope monthly invoice split API
2026-07-06 16:01:54 +02:00
Jeppe B b8390ac0d3 Merge pull request #298 from copenhagentruckwash/codex/only-tankcleaning-order-enforcement
Enforce only tankcleaning order products
2026-07-06 16:01:40 +02:00
Jeppe B 0d4a5470e5 Add superuser department overview API (#301)
Merge backend API for the superuser department overview.
2026-07-06 16:01:04 +02:00
Jeppe B 845ca6e48e Merge pull request #290 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing enforcement
2026-07-06 15:31:27 +02:00
Jeppe Bundgaard 1cda2a81aa Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	services/nginx/app/tests/Api/OrderItemsApiTest.php
2026-07-06 15:21:31 +02:00
Jeppe BandJeppe Bundgaard 8e46ce1b04 [codex] Allow error reports without screenshots (#299)
* Allow error reports without screenshots

* Stabilize edge gateway shell transcript smoke

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:27:47 +02:00
Jeppe Bundgaard 9f797bf6b8 Add opening hours table to API test schema 2026-07-06 14:27:39 +02:00
Jeppe Bundgaard d345db927f Add daily report table to API test schema 2026-07-06 14:16:41 +02:00
Jeppe BandJeppe Bundgaard 11c2a1b72e Block restricted customer order items (#296)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:06:29 +02:00
Jeppe Bundgaard eca7a81f9d Add superuser department overview API 2026-07-06 13:59:40 +02:00
Jeppe Bundgaard 62f2c80dda Scope monthly invoice split endpoint 2026-07-06 13:37:31 +02:00
Jeppe BandJeppe Bundgaard 6f3d7e0f7d Add limited backoffice employee contact fields (#294)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:14:06 +02:00
Jeppe Bundgaard 430c90cbca Enforce only tankcleaning order products 2026-07-06 12:53:42 +02:00
Jeppe Bundgaard f02dfd8c9c Add customer product fixed price overrides 2026-07-06 12:52:33 +02:00
Jeppe B a8fba73d99 Add limited backoffice role permission details (#291)
Adds grouped safe permission metadata for limited backoffice role presets.
2026-07-06 12:24:54 +02:00
Jeppe B 669759461d Merge pull request #293 from copenhagentruckwash/codex/economic-collected-invoice-transfer-speed
Optimize collected e-conomic invoice transfers
2026-07-06 11:49:47 +02:00
Jeppe Bundgaard 38814545c4 Optimize collected e-conomic invoice transfers 2026-07-06 11:31:22 +02:00
Jeppe Bundgaard 215c8d0fbb Add limited backoffice role permission details 2026-07-06 10:38:51 +02:00
Jeppe Bundgaard 84dec4c0a2 Stabilize custom pricing API fixture 2026-07-06 10:34:57 +02:00
Jeppe Bundgaard d47ea1d659 Add custom-only department pricing enforcement 2026-07-06 10:15:11 +02:00
151 changed files with 16451 additions and 2740 deletions
+931 -77
View File
File diff suppressed because it is too large Load Diff
+13 -15
View File
@@ -928,22 +928,20 @@ async function main() {
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
await waitForCondition(
async () => {
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
},
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_schedule
{
public static function normalize(array $schedule): array
{
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
if ($type !== 'interval') {
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
}
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
if ($seconds < 30 || $seconds > 2678400) {
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
}
return [
'type' => 'interval',
'seconds' => $seconds,
];
}
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
{
$normalized = self::normalize($schedule);
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
? strtotime($anchorDateTime)
: false;
$base = $anchor !== false ? (int)$anchor : $now;
$next = $base + (int)$normalized['seconds'];
if ($next <= $now) {
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
$next += $missed * (int)$normalized['seconds'];
}
return date('Y-m-d H:i:s', $next);
}
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
{
$normalized = self::normalize($schedule);
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
return self::nextRunAt($normalized, $lastRunAt, $now);
}
if ($legacyLastRun !== null && $legacyLastRun > 0) {
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
}
return date('Y-m-d H:i:s', $now);
}
}
@@ -0,0 +1,464 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class cron_scheduler
{
private cron_task_registry $registry;
private string $lock_owner;
public function __construct(?cron_task_registry $registry = null)
{
$this->registry = $registry ?? new cron_task_registry();
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
}
public function listTasks(): array
{
$this->ensureReady();
$this->syncDefinitions();
$states = $this->stateRows();
$estimates = $this->durationEstimates();
$tasks = [];
$now = time();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
? $state['schedule']
: $definition->schedule;
$nextRunAt = $state['next_run_at'] ?? null;
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
}
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
$tasks[] = $task;
}
return [
'tasks' => $tasks,
'summary' => [
'total' => count($tasks),
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
],
];
}
public function listRuns(?string $task_id = null, int $limit = 50): array
{
$this->ensureReady();
$limit = max(1, min(200, $limit));
$where = '';
if ($task_id !== null && trim($task_id) !== '') {
$where = "WHERE task_id = " . $this->sql($task_id);
}
return $this->fetchAll(
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
);
}
public function runDue(string $source = 'automatic'): array
{
$this->ensureReady();
$this->syncDefinitions();
$ran = [];
$now = time();
$states = $this->stateRows();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
continue;
}
$nextRunAt = (string)($state['next_run_at'] ?? '');
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
continue;
}
try {
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => $definition->id,
'module' => $definition->module,
'source' => $source,
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
return [
'ran' => $ran,
'count' => count($ran),
];
}
public function runTask(
string $task_id_or_legacy_name,
string $source = 'manual',
?int $actor_user_id = null,
bool $force = false,
?string $scheduled_for = null
): array {
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if (!$this->claimLock($definition)) {
throw new RuntimeException('Cron task is already running.');
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at);
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
$status = 'succeeded';
$summary = [];
$error_message = null;
$output = '';
try {
if (function_exists('set_time_limit')) {
@set_time_limit($definition->timeout_seconds + 30);
}
$this->ensureLegacyFunctionsLoaded($definition);
if (!is_callable($definition->handler)) {
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
}
ob_start();
$result = call_user_func($definition->handler);
$output = (string)ob_get_clean();
$summary = is_array($result) ? $result : [];
} catch (Throwable $throwable) {
if (ob_get_level() > 0) {
$output .= (string)ob_get_clean();
}
$status = 'failed';
$error_message = $throwable->getMessage();
}
$completed = microtime(true);
$duration_ms = (int)round(($completed - $started) * 1000);
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
$status = 'timed_out';
$error_message = 'Task exceeded its configured timeout window.';
}
if ($output !== '') {
$summary['output'] = substr($output, 0, 8000);
}
$completed_at = date('Y-m-d H:i:s', (int)$completed);
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at);
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
return $run;
}
public function updateTaskConfig(string $task_id, array $config): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$updates = [];
if (array_key_exists('enabled', $config)) {
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
}
if (array_key_exists('schedule', $config)) {
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
}
if ($updates !== []) {
$this->query(
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
);
}
return $this->listTasks();
}
private function ensureReady(): void
{
cron_schema_bootstrap::ensureTables();
}
private function syncDefinitions(): void
{
$now = time();
foreach ($this->registry->definitions() as $definition) {
$row = $this->fetchOne(
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
);
if ($row !== null) {
continue;
}
$legacyLastRun = $this->legacyLastRun($definition);
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
$this->query(
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. ($definition->enabled ? '1' : '0') . ', NULL, '
. $this->sql($nextRunAt)
. ")"
);
}
}
private function legacyLastRun(cron_task_definition $definition): ?int
{
if ($definition->legacy_name === null || !defined('redis')) {
return null;
}
try {
$last_run = redis->get_last_crond_run($definition->legacy_name);
return $last_run !== null ? (int)$last_run : null;
} catch (Throwable) {
return null;
}
}
private function claimLock(cron_task_definition $definition): bool
{
$now = date('Y-m-d H:i:s');
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
$this->query(
"UPDATE cron_task_state
SET locked_until = " . $this->sql($locked_until) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
);
return $this->affectedRows() === 1;
}
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
{
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
if ($schedule === []) {
$schedule = $definition->schedule;
}
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
if ($status !== 'succeeded') {
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
}
$this->query(
"UPDATE cron_task_state
SET last_run_at = " . $this->sql($completed_at) . ",
next_run_at = " . $this->sql($nextRunAt) . ",
locked_until = NULL,
lock_owner = NULL,
current_run_id = NULL,
last_status = " . $this->sql($status) . ",
last_error = " . $this->nullableSql($error_message) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
if ($definition->legacy_name !== null && defined('redis')) {
try {
redis->set_last_crond_run($definition->legacy_name, time());
} catch (Throwable) {
}
}
}
private function createRun(
cron_task_definition $definition,
string $source,
?int $actor_user_id,
?string $scheduled_for,
string $started_at
): int {
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. $this->sql($source) . ", 'running', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->nullableSql($scheduled_for) . ', '
. $this->sql($started_at) . ', '
. $this->sql($this->lock_owner)
. ")"
);
return $this->insertId();
}
private function completeRun(
int $run_id,
string $status,
string $completed_at,
int $duration_ms,
array $summary,
?string $error_message
): void {
$this->query(
"UPDATE cron_task_runs
SET status = " . $this->sql($status) . ",
completed_at = " . $this->sql($completed_at) . ",
duration_ms = " . (string)$duration_ms . ",
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
error_message = " . $this->nullableSql($error_message) . "
WHERE id = " . (string)$run_id
);
}
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
{
if (function_exists($definition->handler)) {
return;
}
if (!defined('WD')) {
return;
}
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
}
require_once WD . '/cron/Cron.php';
}
/**
* @return array<string, array<string, mixed>>
*/
private function stateRows(): array
{
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
$states = [];
foreach ($rows as $row) {
$row['enabled'] = (bool)$row['enabled'];
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
$states[(string)$row['task_id']] = $row;
}
return $states;
}
/**
* @return array<string, int>
*/
private function durationEstimates(): array
{
$rows = $this->fetchAll(
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
FROM (
SELECT task_id, duration_ms
FROM cron_task_runs
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
ORDER BY id DESC
LIMIT 500
) recent_runs
GROUP BY task_id"
);
$estimates = [];
foreach ($rows as $row) {
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
}
return $estimates;
}
private function decodeJson(mixed $json): array
{
if (!is_string($json) || trim($json) === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function fetchOne(string $sql): ?array
{
$rows = $this->fetchAll($sql);
return $rows[0] ?? null;
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function nullableSql(?string $value): string
{
return $value === null ? 'NULL' : $this->sql($value);
}
private function affectedRows(): int
{
global $db;
return (int)$db->conn()->affected_rows;
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
}
@@ -0,0 +1,66 @@
<?php
namespace classes;
class cron_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_state (
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
module VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
schedule_json LONGTEXT NULL,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
locked_until DATETIME NULL,
lock_owner VARCHAR(191) NULL,
current_run_id BIGINT UNSIGNED NULL,
last_status VARCHAR(32) NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_state_next_run (enabled, next_run_at),
KEY idx_cron_task_state_lock (locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_runs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(191) NOT NULL,
module VARCHAR(64) NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
actor_user_id INT NULL,
scheduled_for DATETIME NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
duration_ms INT UNSIGNED NULL,
summary_json LONGTEXT NULL,
error_message TEXT NULL,
lock_owner VARCHAR(191) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_runs_task_created (task_id, created_at),
KEY idx_cron_task_runs_status_created (status, created_at),
KEY idx_cron_task_runs_module_created (module, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_definition
{
public string $id;
public string $name;
public string $description;
public string $module;
public string $handler;
public array $schedule;
public bool $enabled;
public int $timeout_seconds;
public int $estimated_duration_ms;
public int $priority;
public ?string $legacy_name;
public function __construct(array $definition)
{
$this->id = self::requiredString($definition, 'id');
$this->name = self::requiredString($definition, 'name');
$this->description = (string)($definition['description'] ?? '');
$this->module = self::requiredString($definition, 'module');
$this->handler = self::requiredString($definition, 'handler');
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
$this->enabled = (bool)($definition['enabled'] ?? true);
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
$this->priority = (int)($definition['priority'] ?? 100);
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
}
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
}
}
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
{
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
? $state['schedule']
: $this->schedule;
$enabled = array_key_exists('enabled', $state ?? [])
? (bool)$state['enabled']
: $this->enabled;
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'module' => $this->module,
'handler' => $this->handler,
'schedule' => $schedule,
'default_schedule' => $this->schedule,
'enabled' => $enabled,
'default_enabled' => $this->enabled,
'timeout_seconds' => $this->timeout_seconds,
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
'priority' => $this->priority,
'legacy_name' => $this->legacy_name,
'last_run_at' => $state['last_run_at'] ?? null,
'next_run_at' => $state['next_run_at'] ?? null,
'locked_until' => $state['locked_until'] ?? null,
'lock_owner' => $state['lock_owner'] ?? null,
'current_run_id' => $state['current_run_id'] ?? null,
'last_status' => $state['last_status'] ?? null,
'last_error' => $state['last_error'] ?? null,
];
}
private static function requiredString(array $definition, string $key): string
{
$value = trim((string)($definition[$key] ?? ''));
if ($value === '') {
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
}
return $value;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_registry
{
private string $modules_root;
/** @var array<string, cron_task_definition>|null */
private ?array $definitions = null;
public function __construct(?string $modules_root = null)
{
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
}
/**
* @return array<string, cron_task_definition>
*/
public function definitions(): array
{
if ($this->definitions !== null) {
return $this->definitions;
}
$definitions = [];
foreach ($this->definitionFiles() as $file) {
$module_definitions = require $file;
if (!is_array($module_definitions)) {
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
}
foreach ($module_definitions as $definition) {
$task = new cron_task_definition($definition);
if (isset($definitions[$task->id])) {
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
}
$definitions[$task->id] = $task;
}
}
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
if ($left->priority !== $right->priority) {
return $left->priority <=> $right->priority;
}
return strcmp($left->id, $right->id);
});
$this->definitions = $definitions;
return $definitions;
}
public function get(string $id_or_legacy_name): ?cron_task_definition
{
$normalized = trim($id_or_legacy_name);
if ($normalized === '') {
return null;
}
$definitions = $this->definitions();
if (isset($definitions[$normalized])) {
return $definitions[$normalized];
}
foreach ($definitions as $definition) {
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
return $definition;
}
}
return null;
}
/**
* @return array<int, string>
*/
private function definitionFiles(): array
{
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
sort($files, SORT_STRING);
return $files;
}
}
@@ -0,0 +1,98 @@
<?php
namespace classes;
use RuntimeException;
class customer_order_product_policy
{
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
{
$message = self::orderProductViolationMessage($orderId, $productId);
if ($message !== null) {
throw new RuntimeException($message);
}
}
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
{
$context = self::loadOrderProductContext($orderId, $productId);
if ($context === null) {
return null;
}
if ((int)($context['product_id'] ?? 0) < 1) {
return null;
}
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
? self::ONLY_TANKCLEANING_MESSAGE
: null;
}
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
{
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
}
public static function isTankCleaningProductRow(array $row): bool
{
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
}
private static function loadOrderProductContext(int $orderId, int $productId): ?array
{
global $db;
if ($orderId < 1 || $productId < 1) {
return null;
}
$sql = "
SELECT
o.id AS order_id,
o.customer_id AS customer_number,
p.id AS product_id,
p.name AS product_name,
p.category AS product_category,
c.name AS category_name,
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
FROM orders o
LEFT JOIN products p ON p.id = {$productId}
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN users u ON u.customer_number = o.customer_id
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
WHERE o.id = {$orderId}
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
LIMIT 1
";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
}
private static function rowMatchesProductTerms(array $row, array $terms): bool
{
$haystack = strtolower(trim(
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
));
foreach ($terms as $term) {
if ($term !== '' && str_contains($haystack, strtolower($term))) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,168 @@
<?php
namespace classes;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
/**
* @return array{rule:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
$order = (new orders_o())->getOrderById($orderId);
if (!$order->exists()) {
return null;
}
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
return null;
}
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
if (!$customer->exists()) {
return null;
}
$categoryId = (int)$product->category->value();
$categoryName = $this->categoryName($categoryId);
$searchableProduct = $this->searchableProductText($product, $categoryName);
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
return $this->violation('restrictAdditionalServices');
}
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
return $this->violation('restrictTankCleaning');
}
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
return $this->violation('onlyTankCleaning');
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
return $this->violation('restrictSpotFree');
}
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
return $this->violation('restrictInteriorCleaning');
}
return null;
}
/**
* @return array{rule:string,message:string}
*/
private function violation(string $rule): array
{
return [
'rule' => $rule,
'message' => self::BLOCK_MESSAGE,
];
}
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
{
if ($relatedItemId !== null && $relatedItemId > 0) {
return true;
}
if ($categoryId === self::ADDON_CATEGORY_ID) {
return true;
}
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
return true;
}
return $this->countStandaloneOrderItems($orderId) > 0;
}
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
{
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
return true;
}
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function isSpotFreeProduct(int $productId, string $searchableProduct): bool
{
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
return true;
}
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
}
/**
* @param array<int, string> $terms
*/
private function containsAny(string $value, array $terms): bool
{
foreach ($terms as $term) {
if ($term !== '' && str_contains($value, $term)) {
return true;
}
}
return false;
}
private function categoryName(int $categoryId): string
{
global $db;
if ($categoryId <= 0) {
return '';
}
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
if (!$result || $result->num_rows === 0) {
return '';
}
$row = $result->fetch_assoc();
return strtolower((string)($row['name'] ?? ''));
}
private function countStandaloneOrderItems(int $orderId): int
{
global $db;
$result = $db->query(
'SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = ' . $orderId . '
AND deleted_at IS NULL
AND (related_item_id IS NULL OR related_item_id = 0)'
);
if (!$result) {
return 0;
}
$row = $result->fetch_assoc();
return (int)($row['item_count'] ?? 0);
}
}
@@ -0,0 +1,44 @@
<?php
namespace classes;
/**
* Ensures additive schema for department-scoped customer price overrides.
*/
class department_customer_price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`user_id` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,353 @@
<?php
namespace classes;
use objects\department_customer_price_overrides_o;
use objects\departments_o;
use objects\products_o;
use objects\users_o;
class department_customer_pricing_service
{
/**
* @return array<string, mixed>
*/
public function getPricing(int $departmentId, int $userId): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
return [
'department' => $department,
'customer' => $customer,
'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updatePricing(int $departmentId, int $userId, array $payload): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
}
$overrides = $payload['overrides'] ?? null;
if (!is_array($overrides)) {
throw new limited_backoffice_exception('Overrides are required.', 400);
}
$normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o();
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$normalizedKeys = [];
foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
}
global $db;
$db->conn()->begin_transaction();
try {
$db->query(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
);
foreach ($normalized as $override) {
$overrideObject->setPrice(
$departmentId,
$customer['id'],
(bool)$override['is_category'],
$override['product_or_category_id'],
(int)$override['percentage'],
$override['fixed_price']
);
}
$db->conn()->commit();
} catch (\Throwable) {
$db->conn()->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
}
foreach ($normalized as $override) {
$this->recordVersion($customer, $departmentId, $override);
}
foreach ($existingOverrides as $existingOverride) {
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
if (isset($normalizedKeys[$key])) {
continue;
}
$this->recordVersion($customer, $departmentId, [
'is_category' => (bool)$existingOverride['is_category'],
'product_or_category_id' => $existingOverride['product_or_category_id'],
'percentage' => 0,
'fixed_price' => null,
]);
}
return $this->getPricing($departmentId, $customer['id']);
}
/**
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function department(int $departmentId): array
{
$department = (new departments_o())->getDepartmentById($departmentId);
if (!is_array($department) || empty($department)) {
throw new limited_backoffice_exception('Department not found', 404);
}
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'description' => (string)($department['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{id:int,customer_number:int,display_name:string}
*/
private function customer(int $userId): array
{
$customer = (new users_o())->getUserById($userId);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return [
'id' => (int)$customer->id,
'customer_number' => (int)$customer->customer_number->value(),
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
];
}
/**
* @param array<string, mixed> $department
*/
private function assertEnabled(array $department): void
{
if (!($department['custom_pricing_only'] ?? false)) {
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
'message' => 'Department customer pricing is disabled.',
'code' => 'department_customer_pricing_disabled',
'department' => $department,
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(int $departmentId, int $userId): array
{
global $db;
$sql = "
SELECT
c.`id` AS `category_id`,
c.`name` AS `category_name`,
c.`description` AS `category_description`,
p.*,
pdp.`price` AS `department_price`
FROM `department_categories` dc
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id`
AND pdp.`product_id` = p.`id`
WHERE dc.`department_id` = " . (int)$departmentId . "
AND dc.`deleted_at` IS NULL
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
$result = $db->query($sql);
$rows = $result ? $db->fetch_all($result) : [];
$customer = (new users_o())->getUserById($userId);
$categories = [];
$seen = [];
foreach ($rows as $row) {
$productId = (int)$row['id'];
if (isset($seen[$productId])) {
continue;
}
$seen[$productId] = true;
$categoryId = (int)$row['category_id'];
if (!isset($categories[$categoryId])) {
$categories[$categoryId] = [
'id' => $categoryId,
'name' => (string)$row['category_name'],
'description' => (string)($row['category_description'] ?? ''),
'products' => [],
];
}
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
if ($departmentPrice !== null) {
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
}
$categories[$categoryId]['products'][] = [
'id' => $productId,
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'category' => $categoryId,
'apply_category_discount' => (bool)$row['apply_category_discount'],
'base_price' => (int)$row['price'],
'department_price' => $departmentPrice,
'effective_price' => $effectivePrice,
'missing_department_price' => $departmentPrice === null,
];
}
return array_values($categories);
}
/**
* @param array<int, mixed> $overrides
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
*/
private function normalizeOverrides(int $departmentId, array $overrides): array
{
$normalized = [];
foreach ($overrides as $override) {
if (!is_array($override)) {
throw new limited_backoffice_exception('Invalid override payload.', 400);
}
$isCategory = (bool)($override['is_category'] ?? false);
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
if ($objectId === null || $objectId === '') {
throw new limited_backoffice_exception('Override object is required.', 400);
}
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
if ($percentage === false || $percentage < 0 || $percentage > 100) {
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
}
$fixedPrice = null;
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
if ($fixedPrice === false || $fixedPrice < 0) {
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
}
}
if ($isCategory) {
$fixedPrice = null;
$objectId = (string)$objectId;
if ($objectId !== 'global') {
$this->assertDepartmentCategory($departmentId, $objectId);
}
} else {
$objectId = (int)$objectId;
$this->assertDepartmentProduct($departmentId, $objectId);
}
if ($percentage <= 0 && $fixedPrice === null) {
continue;
}
$key = $this->overrideKey($isCategory, $objectId);
$normalized[$key] = [
'is_category' => $isCategory,
'product_or_category_id' => $objectId,
'percentage' => (int)$percentage,
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
];
}
return array_values($normalized);
}
/**
* @param array{id:int,customer_number:int,display_name:string} $customer
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
*/
private function recordVersion(array $customer, int $departmentId, array $override): void
{
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$customer['id'],
(int)$customer['customer_number'],
(bool)$override['is_category'],
(string)$override['product_or_category_id'],
(int)$override['percentage'],
date('Y-m-d H:i:s'),
'live.department_discount_override.route',
1.0,
false,
[
'route' => 'department_customer_pricing',
'department_id' => $departmentId,
],
$override['fixed_price'],
$departmentId
);
} catch (\Throwable) {
}
}
private function overrideKey(bool $isCategory, int|string $objectId): string
{
return ((int)$isCategory) . ':' . (string)$objectId;
}
private function assertDepartmentProduct(int $departmentId, int $productId): void
{
global $db;
$result = $db->query(
'SELECT p.`id`
FROM `department_categories` dc
INNER JOIN `products` p ON p.`category` = dc.`category_id`
WHERE dc.`department_id` = ' . (int)$departmentId . '
AND dc.`deleted_at` IS NULL
AND p.`id` = ' . (int)$productId . '
LIMIT 1'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Product is not available for this department.', 400);
}
}
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
{
global $db;
$categoryId = $db->escape_string($categoryId);
$result = $db->query(
"SELECT `id`
FROM `department_categories`
WHERE `department_id` = " . (int)$departmentId . "
AND `deleted_at` IS NULL
AND `category_id` = '{$categoryId}'
LIMIT 1"
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Category is not available for this department.', 400);
}
}
}
@@ -0,0 +1,252 @@
<?php
namespace classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
use Exception;
class department_wash_count_service
{
/**
* @throws Exception
*/
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT deduped.department_id,
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(*) AS wash_count
FROM (
SELECT dedupe_key,
department_id,
MIN(counted_at) AS counted_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
) deduped
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
ORDER BY deduped.department_id ASC, hour_bucket ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
}
/**
* @param array<int|string> $department_ids
* @return array{quantity:int,products:int,earnings:int,washes:int}
* @throws Exception
*/
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [
'quantity' => 0,
'products' => 0,
'earnings' => 0,
'washes' => 0,
];
}
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
COALESCE(SUM(oi.quantity), 0) AS products,
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL";
$result = $db->query($sql);
$row = is_object($result) ? $result->fetch_assoc() : null;
return [
'quantity' => (int)($row['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
'washes' => $this->countRows($date_start, $date_end, $normalized_department_ids),
];
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{id:int,department_id:int,created_at:string}>
* @throws Exception
*/
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
department_id,
MIN(counted_at) AS created_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
ORDER BY created_at ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'department_id' => (int)($row['department_id'] ?? 0),
'created_at' => (string)($row['created_at'] ?? ''),
];
}
return $rows;
}
/**
* @param array<int> $department_ids
* @throws Exception
*/
private function countRows(string $date_start, string $date_end, array $department_ids): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
{
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
o.id AS entity_id,
o.department_id,
o.created_at AS counted_at,
0 AS source_priority
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND p.is_wash = 1
UNION ALL
SELECT CASE
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
ELSE CONCAT('selfserve:', s.id)
END AS dedupe_key,
CASE
WHEN linked_o.id IS NOT NULL THEN linked_o.id
ELSE s.id
END AS entity_id,
COALESCE(linked_o.department_id, s.department_id) AS department_id,
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
1 AS source_priority
FROM selfserve_wash_sessions s
LEFT JOIN orders linked_o
ON linked_o.id = s.order_id
AND linked_o.deleted_at IS NULL
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
AND s.deleted_at IS NULL
AND s.completed_at IS NOT NULL
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
}
/**
* @param array<int|string> $ids
* @return array<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$value = (int)$id;
if ($value > 0) {
$normalized[$value] = $value;
}
}
return array_values($normalized);
}
/**
* @throws Exception
*/
private function validateDateRange(string $date_start, string $date_end): void
{
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
}
}
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
);
}
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
AFTER archived"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
@@ -240,7 +240,13 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
return $collected_order_invoices->asArray();
$result = $collected_order_invoices->asArray();
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
if ($transfer_metrics !== null) {
$result['economic_transfer_metrics'] = $transfer_metrics;
}
return $result;
}
/**
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic;
}
@@ -388,7 +389,16 @@ class economic_v2_distribution_service
continue;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id);
if ($discount_row === null) {
continue;
}
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$fixed_price = (float)$discount_row['fixed_price'];
$order_discount_total += (($base_price - $fixed_price) * $quantity);
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) {
continue;
@@ -1519,7 +1529,13 @@ class economic_v2_distribution_service
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id);
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
$total += $line_price;
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100));
@@ -1529,15 +1545,22 @@ class economic_v2_distribution_service
return $total;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
$cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . substr($timestamp, 0, 19);
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
return $this->discount_resolution_cache[$cache_key];
}
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
$scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
? (int)$department_id
: null;
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId);
if ($direct !== null && (
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|| (int)($direct['discount'] ?? 0) > 0
)) {
return $this->discount_resolution_cache[$cache_key] = $direct;
}
@@ -1545,13 +1568,20 @@ class economic_v2_distribution_service
if ($product !== null) {
$category = (string)$product->category->value();
if ($category !== '') {
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId);
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount;
}
}
}
if ($scopedDepartmentId !== null) {
$global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId);
if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $global_discount;
}
}
return $this->discount_resolution_cache[$cache_key] = null;
}
@@ -60,11 +60,13 @@ class economic_v2_schema_bootstrap
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL DEFAULT NULL,
user_id INT NOT NULL,
customer_number INT NOT NULL,
is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -74,6 +76,7 @@ class economic_v2_schema_bootstrap
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_user (user_id),
INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER discount"
);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN department_id INT NULL DEFAULT NULL
AFTER id"
);
}
self::$initialized = true;
}
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
string $source = 'live.discount_override',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
array $metadata = [],
?int $fixed_price = null,
?int $department_id = null
): array {
$identity = [
'user_id' => $user_id,
@@ -143,8 +145,11 @@ class economic_v2_versioning_service
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
if ($discount === null || (int)$discount === 0) {
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion(
'customer_discount_override_versions',
$identity,
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
$identity,
[
'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
],
$this->normalizeDatetime($effective_from),
$source,
@@ -238,15 +244,21 @@ class economic_v2_versioning_service
int $customer_number,
bool $is_category,
int|string $object_id,
string $timestamp
string $timestamp,
?int $department_id = null
): ?array {
$identity = [
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
$rows = $this->resolveActiveVersions(
'customer_discount_override_versions',
[
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
],
$identity,
$timestamp,
'effective_from DESC, id DESC',
1
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
}
// Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
($has_override_fixed_price ? ', po.fixed_price' : '') .
($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll(
"SELECT $discount_cols
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override',
$confidence,
true,
['table' => 'price_overrides']
['table' => 'price_overrides'],
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
);
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
}
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
$bucket[$action]++;
}
}
@@ -92,12 +92,17 @@ class error_report_service
throw new RuntimeException('Data collection acceptance is required.');
}
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
$runtimeContext = $this->runtimeContext($payload, $context);
$runtimeContext['screenshot_attachment'] = [
'status' => $storedScreenshot['status'],
'attached' => $storedScreenshot['key'] !== '',
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
'size_bytes' => (int)$storedScreenshot['size_bytes'],
];
$this->execute(
"INSERT INTO error_reports (
@@ -295,6 +300,67 @@ class error_report_service
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function storeOptionalScreenshot(mixed $value, array $context): array
{
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
return $this->emptyScreenshotAttachment('invalid');
}
$dataUri = trim((string)($value ?? ''));
if ($dataUri === '') {
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
}
try {
$screenshot = self::decodeScreenshotDataUri($dataUri);
} catch (RuntimeException $exception) {
$message = strtolower($exception->getMessage());
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
}
try {
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
} catch (Throwable) {
return $this->emptyScreenshotAttachment('storage_failed');
}
return [
'key' => (string)($storedScreenshot['key'] ?? ''),
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
'status' => 'stored',
];
}
private function emptyScreenshotAttachment(string $status): array
{
return [
'key' => '',
'mime_type' => '',
'size_bytes' => 0,
'status' => $status,
];
}
private function contextScreenshotStatus(array $context): ?string
{
$attachment = $context['screenshot_attachment'] ?? null;
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
return $this->normalizeEmptyScreenshotStatus($status);
}
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
{
$status = strtolower(trim((string)$status));
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
return $status;
}
return null;
}
private function runtimeContext(array $payload, array $context): array
{
return [
@@ -432,6 +498,10 @@ class error_report_service
private function publicReport(array $row, bool $includeDetail): array
{
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
@@ -449,10 +519,10 @@ class error_report_service
'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null,
'screenshot' => [
'mime_type' => $row['screenshot_mime_type'] ?? null,
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
],
'screenshot' => $hasScreenshot ? [
'mime_type' => $screenshotMimeType,
'size_bytes' => $screenshotSizeBytes,
] : null,
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
@@ -467,8 +537,11 @@ class error_report_service
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
if ($hasScreenshot) {
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
}
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
@@ -0,0 +1,617 @@
<?php
namespace classes;
use Exception;
use objects\collected_order_invoices_o;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class invoice_collection_bulk_action_service
{
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
public const ACTION_MERGE = 'merge_collections';
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
private const PREVIEW_TTL_SECONDS = 600;
private const MAX_COLLECTIONS = 100;
private const CONFIRMATION_PHRASES = [
'da' => 'Bekræft',
'en' => 'Confirm',
'sv' => 'Bekräfta',
'no' => 'Bekreft',
'de' => 'Bestätigen',
];
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
{
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
$previewId = $this->previewId();
$preview['preview_id'] = $previewId;
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
$this->cachePreview($previewId, [
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
'locale' => $locale,
'selection_hash' => $preview['selection_hash'],
'preview' => $preview,
]);
return $preview;
}
public function apply(
string $previewId,
string $action,
array $invoiceCollectionIds,
array $options,
string $confirmationText,
int $actorUserId,
string $locale = 'da'
): array {
global $db;
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$cached = $this->getCachedPreview($previewId);
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
}
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
if (trim($confirmationText) !== $expectedConfirmation) {
throw new Exception('Confirmation text does not match.');
}
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
if (!empty($freshPreview['blockers'])) {
throw new Exception('Action cannot be applied while blockers are present.');
}
$db->conn()->begin_transaction();
try {
$result = match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
self::ACTION_QUEUE_ECONOMIC => [
'queued_invoice_collection_ids' => $invoiceCollectionIds,
'changed_count' => count($invoiceCollectionIds),
],
default => throw new Exception('Unsupported action'),
};
(new logs_o())->add(
'orderInvoices',
'global',
1,
$actorUserId,
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
);
$db->conn()->commit();
} catch (\Throwable $e) {
$db->conn()->rollback();
throw $e;
}
$this->deleteCachedPreview($previewId);
return [
...$freshPreview,
'preview' => false,
'result' => $result,
];
}
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
{
$collections = $this->loadCollections($invoiceCollectionIds);
$base = [
'action' => $action,
'preview' => true,
'confirmation_phrase' => $this->confirmationPhrase($locale),
'invoice_collection_ids' => $invoiceCollectionIds,
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
'warnings' => [],
'blockers' => [],
];
return match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
default => throw new Exception('Unsupported action'),
};
}
private function previewCleanCustomerRules(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
$rows = $this->orderItemRows((int)$collection->id);
$violatingItemIds = [];
$includedItemIds = [];
foreach ($rows as $row) {
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
(int)$row['order_id'],
(int)$row['product_id'],
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
);
if ($violation === null) {
continue;
}
$violatingItemIds[] = (int)$row['order_item_id'];
$includedItemIds[] = (int)$row['order_item_id'];
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => (string)$violation['rule'],
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
foreach ($rows as $row) {
$orderItemId = (int)$row['order_item_id'];
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
continue;
}
$includedItemIds[] = $orderItemId;
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => $orderItemId,
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => 'related_to_removed_item',
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewMerge(array $preview, array $collections, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$blockers = [];
if (count($collections) < 2) {
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
}
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
}
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
if (count($customerNumbers) !== 1) {
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
}
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
}
$ordersToMove = [];
foreach ($collections as $collection) {
if ((int)$collection->id === $targetId) {
continue;
}
foreach ($collection->getOrderIds() as $orderIdRow) {
$ordersToMove[] = [
'order_id' => (int)$orderIdRow['id'],
'source_invoice_collection_id' => (int)$collection->id,
'target_invoice_collection_id' => $targetId,
];
}
}
return [
...$preview,
'target_invoice_collection_id' => $targetId,
'orders' => $ordersToMove,
'summary' => [
'collections' => count($collections),
'orders_to_move' => count($ordersToMove),
'changed_count' => count($ordersToMove),
],
'blockers' => $blockers,
];
}
private function previewSplitByMonth(array $preview, array $collections): array
{
$items = [];
$changed = [];
$skipped = [];
foreach ($collections as $collection) {
try {
$item = [
'invoice_collection_id' => (int)$collection->id,
...$collection->previewSplitByOrderMonth(),
];
if (($item['status'] ?? '') === 'changed') {
$changed[] = $item;
} else {
$skipped[] = $item;
}
$items[] = $item;
} catch (\Throwable $e) {
$item = [
'status' => 'skipped',
'invoice_collection_id' => (int)$collection->id,
'reason' => 'not_splittable',
'message' => $e->getMessage(),
];
$skipped[] = $item;
$items[] = $item;
}
}
return [
...$preview,
'items' => $items,
'changed' => $changed,
'skipped' => $skipped,
'summary' => [
'collections' => count($collections),
'changed_count' => count($changed),
'skipped_count' => count($skipped),
],
];
}
private function previewResetHiddenPrices(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
if ((int)$row['include_in_invoice'] !== 0) {
continue;
}
$order = (new orders_o())->select((int)$row['order_id']);
$product = (new products_o())->select((int)$row['product_id']);
if (!$order->exists() || !$product->exists()) {
continue;
}
$newPrice = (int)$order->getCustomerProductPrice($product);
if ((int)$row['price'] === $newPrice) {
continue;
}
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'current_price' => (int)$row['price'],
'new_price' => $newPrice,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewQueueEconomic(array $preview, array $collections): array
{
$blockers = [];
foreach ($collections as $collection) {
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = [
'code' => 'collection_booked',
'invoice_collection_id' => (int)$collection->id,
'message' => 'Invoice collection is already booked.',
];
}
}
return [
...$preview,
'summary' => [
'collections' => count($collections),
'changed_count' => count($collections),
],
'blockers' => $blockers,
];
}
private function applyCleanCustomerRules(array $preview): array
{
global $db;
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
if ($itemIds === []) {
return ['changed_count' => 0, 'order_item_ids' => []];
}
$ids = implode(',', array_map('intval', $itemIds));
$now = date('Y-m-d H:i:s');
$safeNow = $db->escape_string($now);
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
$this->touchOrdersForItems($itemIds);
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
}
private function applyMerge(array $preview, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$moved = [];
foreach ($preview['orders'] ?? [] as $row) {
$order = (new orders_o())->select((int)$row['order_id']);
if (!$order->exists()) {
continue;
}
$order->assignToInvoiceCollection($targetId);
$moved[] = (int)$order->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
}
private function applySplitByMonth(array $preview): array
{
$changed = [];
$skipped = [];
foreach ($preview['items'] ?? [] as $item) {
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
$skipped[] = $item;
continue;
}
try {
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
} catch (\Throwable $e) {
$skipped[] = [
'invoice_collection_id' => $invoiceCollectionId,
'status' => 'skipped',
'message' => $e->getMessage(),
];
}
}
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
}
private function applyResetHiddenPrices(array $preview): array
{
$changed = [];
foreach ($preview['order_items'] ?? [] as $item) {
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
if (!$orderItem->exists()) {
continue;
}
$orderItem->price->set((int)$item['new_price']);
$orderItem->objectChanged();
$changed[] = (int)$orderItem->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
}
private function contentMutationBlockers(collected_order_invoices_o $collection): array
{
$blockers = [];
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
}
if (!empty($collection->external_id->value())) {
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
}
return $blockers;
}
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
{
global $db;
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
$sql = "
SELECT
oi.id AS order_item_id,
oi.order_id,
oi.product_id,
oi.related_item_id,
oi.include_in_invoice,
oi.price,
oi.quantity,
p.name AS product_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.invoice_collection_id = {$invoiceCollectionId}
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
{$hiddenCondition}
ORDER BY o.id ASC, oi.id ASC
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function touchOrdersForItems(array $itemIds): void
{
global $db;
if ($itemIds === []) {
return;
}
$ids = implode(',', array_map('intval', $itemIds));
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
if (!$result) {
return;
}
while ($row = $result->fetch_assoc()) {
$order = (new orders_o())->select((int)$row['order_id']);
if ($order->exists()) {
$order->objectChanged();
}
}
}
private function touchCollections(array $invoiceCollectionIds): void
{
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
if ($collection->exists()) {
$collection->objectChanged();
}
}
}
private function loadCollections(array $invoiceCollectionIds): array
{
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
$collection->requireSelected();
return $collection;
}, $invoiceCollectionIds);
}
private function collectionSummary(collected_order_invoices_o $collection): array
{
return [
'id' => (int)$collection->id,
'customer_number' => (int)$collection->customer_number->value(),
'name' => (string)$collection->name->value(),
'created_at' => (string)$collection->created_at->value(),
'closed_at' => $collection->closed_at->value(),
'booked_invoice_id' => $collection->booked_invoice_id->value(),
'external_id' => $collection->external_id->value(),
'order_count' => (int)$collection->getOrders(true),
];
}
private function normalizeAction(string $action): string
{
$action = trim($action);
if (!in_array($action, [
self::ACTION_CLEAN_CUSTOMER_RULES,
self::ACTION_MERGE,
self::ACTION_SPLIT_BY_MONTH,
self::ACTION_RESET_HIDDEN_PRICES,
self::ACTION_QUEUE_ECONOMIC,
], true)) {
throw new Exception('Invalid invoice collection bulk action.');
}
return $action;
}
private function normalizeInvoiceCollectionIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
if (is_array($id) || is_object($id) || !is_numeric($id)) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$parsed = (int)$id;
if ($parsed < 1 || $parsed > 999999999) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$normalized[$parsed] = $parsed;
}
$normalized = array_values($normalized);
sort($normalized);
if ($normalized === []) {
throw new Exception('invoice_collection_ids must contain at least one id.');
}
if (count($normalized) > self::MAX_COLLECTIONS) {
throw new Exception('Too many invoice collections selected.');
}
return $normalized;
}
private function normalizeOptions(array $options): array
{
if (isset($options['target_invoice_collection_id'])) {
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
}
ksort($options);
return $options;
}
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
{
return hash('sha256', json_encode([
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function confirmationPhrase(string $locale): string
{
$language = strtolower(substr(trim($locale), 0, 2));
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
}
private function previewId(): string
{
return bin2hex(random_bytes(16));
}
private function previewCacheKey(string $previewId): string
{
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
}
private function cachePreview(string $previewId, array $payload): void
{
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
}
private function getCachedPreview(string $previewId): ?array
{
$raw = (new redis())->get($this->previewCacheKey($previewId));
if (!$raw) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function deleteCachedPreview(string $previewId): void
{
(new redis())->delete($this->previewCacheKey($previewId));
}
}
@@ -22,6 +22,7 @@ class invoice_period_flag_service
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
private array $economicCustomerDiscountCache = [];
private array $userDisplayNameCache = [];
private array $orderItemsPreviewCache = [];
@@ -30,6 +31,8 @@ class invoice_period_flag_service
public function __construct()
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
department_customer_price_overrides_schema_bootstrap::ensureTables();
}
public function createManualFlag(array $payload, int $userId): array
@@ -688,6 +691,7 @@ class invoice_period_flag_service
o.po AS order_po,
o.notes AS order_notes,
o.department_id,
d.custom_pricing_only AS department_custom_pricing_only,
o.reg_1,
o.invoice_collection_id,
o.wash_id,
@@ -710,8 +714,21 @@ class invoice_period_flag_service
p.max_quantity_per_order,
c.name AS category_name,
pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage,
category_discount.percentage AS category_discount_percentage
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
ELSE product_discount.percentage
END AS product_discount_percentage,
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
ELSE product_discount.fixed_price
END AS product_fixed_price,
CASE
WHEN d.custom_pricing_only = 1 THEN GREATEST(
COALESCE(department_category_discount.percentage, 0),
COALESCE(department_global_discount.percentage, 0)
)
ELSE category_discount.percentage
END AS category_discount_percentage
FROM orders o
LEFT JOIN (
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
@@ -720,11 +737,12 @@ class invoice_period_flag_service
GROUP BY customer_number
) u ON u.customer_number = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
LEFT JOIN departments d ON d.id = o.department_id
LEFT JOIN products p ON p.id = oi.product_id
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
LEFT JOIN (
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0
@@ -741,6 +759,32 @@ class invoice_period_flag_service
) category_discount
ON category_discount.customer_number = o.customer_id
AND category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
FROM department_customer_price_overrides
WHERE is_category = 0
GROUP BY department_id, user_id, product_or_category_id
) department_product_discount
ON department_product_discount.department_id = o.department_id
AND department_product_discount.user_id = u.id
AND department_product_discount.product_or_category_id = p.id
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id <> 'global'
GROUP BY department_id, user_id, product_or_category_id
) department_category_discount
ON department_category_discount.department_id = o.department_id
AND department_category_discount.user_id = u.id
AND department_category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id = 'global'
GROUP BY department_id, user_id
) department_global_discount
ON department_global_discount.department_id = o.department_id
AND department_global_discount.user_id = u.id
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
AND o.deleted_at IS NULL
ORDER BY o.customer_id, o.id, oi.id";
@@ -896,7 +940,7 @@ class invoice_period_flag_service
}
$restrictedProducts = [
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
];
@@ -1921,7 +1965,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int
{
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
if ($customMissingPrice) {
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
}
$fixedPrice = $this->rowProductFixedPrice($row);
if ($fixedPrice !== null) {
return $fixedPrice;
}
$base = $row['department_price'] !== null
? (int)$row['department_price']
: (int)($row['product_base_price'] ?? 0);
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100)));
}
@@ -1929,13 +1985,18 @@ class invoice_period_flag_service
private function priceBreakdown(array $row, int $expected): array
{
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
$discount = $this->discountBreakdown($row);
if ($customMissingPrice) {
$discount['applied_discount_percentage'] = 0;
}
return [
'product_price' => (int)($row['product_base_price'] ?? 0),
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
'department_price' => $departmentPrice,
'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1944,21 +2005,36 @@ class invoice_period_flag_service
];
}
private function isCustomMissingDepartmentPrice(array $row): bool
{
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
}
private function discountBreakdown(array $row): array
{
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [
'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount,
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
'applied_discount_percentage' => $appliedDiscount,
];
}
private function rowProductFixedPrice(array $row): ?int
{
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
? (int)$row['product_fixed_price']
: null;
}
private function economicCustomerDiscountPercentage(array $row): int
{
$customerNumber = (int)($row['customer_number'] ?? 0);
@@ -2022,6 +2098,11 @@ class invoice_period_flag_service
private function rowMatchesProductTerms(array $row, array $terms): bool
{
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
return true;
}
$haystack = strtolower(trim(
(string)($row['product_name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
@@ -2036,8 +2117,7 @@ class invoice_period_flag_service
private function rowIsTankCleaningProduct(array $row): bool
{
return (int)($row['product_category'] ?? 0) === 5
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
return customer_order_product_policy::isTankCleaningProductRow($row);
}
private function isIncludedOrderItem(array $row): bool
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for customer product price overrides.
*/
class price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureColumns(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'price_overrides')) {
return;
}
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
$db->query(
"ALTER TABLE price_overrides
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER percentage"
);
}
self::$initialized = true;
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function escapeIdentifier(string $value): string
{
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
}
}
@@ -73,7 +73,9 @@ class selfserve_schema_bootstrap
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number),
INDEX idx_selfserve_wash_sessions_created_at (created_at)
INDEX idx_selfserve_wash_sessions_created_at (created_at),
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
INDEX idx_selfserve_wash_sessions_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
@@ -219,6 +221,16 @@ class selfserve_schema_bootstrap
'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_department_completed',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_order',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)'
);
self::$initialized = true;
}
@@ -252,6 +264,35 @@ class selfserve_schema_bootstrap
$db->query($alterSql);
}
public static function tableHasIndex(string $table, string $index): bool
{
global $db;
$table = $db->escape_string($table);
$index = $db->escape_string($index);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT COUNT(*) AS c
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND INDEX_NAME = '$index'";
$result = $db->query($sql);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
public static function ensureIndex(string $table, string $index, string $alterSql): void
{
global $db;
if (self::tableHasIndex($table, $index)) {
return;
}
$db->query($alterSql);
}
/**
* @param array<int,string> $acceptedDataTypes
*/
@@ -0,0 +1,276 @@
<?php
namespace classes;
use modules\subusers\helpers\subusers_permission_node_key;
class subuser_permission_templates_service
{
public const TEMPLATE_DEACTIVATED = 'deactivated';
public const TEMPLATE_DRIVER = 'driver';
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
public const TEMPLATE_CUSTOM = 'custom';
/**
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
*/
private const TEMPLATES = [
self::TEMPLATE_DEACTIVATED => [
'label' => 'Deactivated',
'description' => 'Keeps the driver linked to the customer without active access.',
'enabled' => false,
'permissions' => [],
],
self::TEMPLATE_DRIVER => [
'label' => 'Driver',
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'SELFSERVE_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'ORDERS_LIST',
],
],
self::TEMPLATE_BOOKING_COORDINATOR => [
'label' => 'Booking coordinator',
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'ORDERS_LIST',
],
],
self::TEMPLATE_FLEET_ADMIN => [
'label' => 'Fleet admin',
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'VEHICLES_EDIT',
'VEHICLES_DELETE',
'VEHICLES_ADD',
'SELFSERVE_LIST',
'SELFSERVE_EDIT',
'SELFSERVE_DELETE',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
'BOOKINGS_ADD',
'ORDERS_LIST',
'ORDERS_EDIT',
'SUBUSERS_LIST',
'SUBUSERS_EDIT',
'SUBUSERS_DELETE',
'SUBUSERS_ADD',
],
],
];
/**
* @var array<string, array{group:string,capability:string}>
*/
private const PERMISSION_CAPABILITIES = [
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
];
/**
* @var array<int, string>
*/
private const GROUP_ORDER = [
'vehicles',
'selfserve',
'bookings',
'orders',
'driver_management',
];
/**
* @return array<string, mixed>
*/
public function accessModel(): array
{
return [
'templates' => $this->templates(),
'groups' => $this->groups(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function templates(): array
{
$templates = [];
foreach (self::TEMPLATES as $key => $template) {
$templates[] = [
'key' => $key,
'label' => $template['label'],
'description' => $template['description'],
'enabled' => $template['enabled'],
'permissions' => array_values($template['permissions']),
'permission_groups' => $this->permissionGroups($template['permissions']),
];
}
return $templates;
}
/**
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function groups(): array
{
$groups = [];
foreach (self::GROUP_ORDER as $group) {
$capabilities = [];
foreach (self::PERMISSION_CAPABILITIES as $capability) {
if ($capability['group'] === $group) {
$capabilities[] = $capability['capability'];
}
}
$groups[] = [
'key' => $group,
'capabilities' => array_values(array_unique($capabilities)),
];
}
return $groups;
}
/**
* @return array{enabled:bool,permissions:array<int,string>}
*/
public function expandTemplate(string $templateKey): array
{
$key = $this->normalizeTemplateKey($templateKey);
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
throw new \InvalidArgumentException('Unknown driver access template.');
}
return [
'enabled' => self::TEMPLATES[$key]['enabled'],
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
];
}
public function normalizeTemplateKey(?string $templateKey): ?string
{
if ($templateKey === null) {
return null;
}
$key = strtolower(trim($templateKey));
if ($key === self::TEMPLATE_CUSTOM) {
return self::TEMPLATE_CUSTOM;
}
return array_key_exists($key, self::TEMPLATES) ? $key : null;
}
/**
* @param array<int, string> $permissions
*/
public function classify(array $permissions, bool $enabled = true): string
{
$normalized = $this->normalizePermissions($permissions);
if (!$enabled || $normalized === []) {
return self::TEMPLATE_DEACTIVATED;
}
foreach (self::TEMPLATES as $key => $template) {
if (!$template['enabled']) {
continue;
}
if ($normalized === $this->normalizePermissions($template['permissions'])) {
return $key;
}
}
return self::TEMPLATE_CUSTOM;
}
/**
* @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function permissionGroups(array $permissions): array
{
$permissions = $this->normalizePermissions($permissions);
$groups = [];
foreach ($permissions as $permission) {
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
if ($capability === null) {
continue;
}
$group = $capability['group'];
$groups[$group] ??= [];
$groups[$group][] = $capability['capability'];
}
$payload = [];
foreach (self::GROUP_ORDER as $group) {
if (!isset($groups[$group])) {
continue;
}
$payload[] = [
'key' => $group,
'capabilities' => array_values(array_unique($groups[$group])),
];
}
return $payload;
}
/**
* @param array<int, string> $permissions
* @return array<int, string>
*/
private function normalizePermissions(array $permissions): array
{
$normalized = [];
foreach ($permissions as $permission) {
if ($permission instanceof subusers_permission_node_key) {
$permission = $permission->name;
}
if (!is_string($permission)) {
continue;
}
$permission = strtoupper(trim($permission));
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
$normalized[] = $permission;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
}
@@ -6,9 +6,8 @@ use Throwable;
class system_search_cache
{
public const PREFIX = 'system_search:v1:';
public const PREFIX = 'system_search:v2:';
public const QUERY_PREFIX = self::PREFIX . 'query:';
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
@@ -40,24 +39,6 @@ class system_search_cache
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function getIntent(string $hash): ?array
{
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
{
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
@@ -68,11 +49,6 @@ class system_search_cache
self::clearPattern(self::QUERY_PREFIX . '*');
}
public static function clearIntentCaches(): void
{
self::clearPattern(self::INTENT_PREFIX . '*');
}
public static function markDirtyTable(string $table): void
{
$table = trim($table, " `\t\n\r\0\x0B");
@@ -499,6 +499,7 @@ class system_search_document_index
*/
private function buildCustomerDiscountDocuments(): array
{
price_overrides_schema_bootstrap::ensureColumns();
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
$selectFields = [
'po.id AS entity_id',
@@ -506,6 +507,7 @@ class system_search_document_index
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'u.customer_number',
'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'),
@@ -554,6 +556,7 @@ class system_search_document_index
$row['search_text'] ?? null,
$row['product_or_category_id'] ?? null,
$row['percentage'] ?? null,
$row['fixed_price'] ?? null,
$row['user_id'] ?? null,
]),
$this->toIntOrNull($row['customer_number'] ?? null),
@@ -564,6 +567,7 @@ class system_search_document_index
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? null,
@@ -1,317 +0,0 @@
<?php
namespace classes;
use Exception;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_openai_intent_parser implements system_search_intent_parser_i
{
private string $apiUrl = 'https://api.openai.com/v1/responses';
private string $model = 'gpt-4.1-mini';
private float $temperature = 0.1;
private int $timeoutSeconds = 10;
private int $maxAliases = 12;
private int $maxEntityHints = 8;
private int $maxAliasLength = 64;
private int $maxHintLength = 32;
private int $maxNormalizedQueryLength = 256;
private int $maxFallbackReasonLength = 160;
/**
* @var callable|null
*/
private $transport;
private ?bool $forcedEnabled;
private ?string $forcedApiKey;
public function __construct(?callable $transport = null, ?bool $forcedEnabled = null, ?string $forcedApiKey = null)
{
$this->transport = $transport;
$this->forcedEnabled = $forcedEnabled;
$this->forcedApiKey = $forcedApiKey;
}
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
$query = trim($query);
if ($query === '') {
return $this->failed('empty_query', 'none');
}
[$enabled, $apiKey] = $this->resolveOpenAISettings();
if (!$enabled) {
return $this->failed('openai_disabled', 'none');
}
if (empty($apiKey)) {
return $this->failed('openai_missing_key', 'none');
}
$redactedQuery = self::redactSensitiveQuery($query);
$payload = $this->buildPayload($redactedQuery, $allowedEntityTypes, $taxonomy);
$cacheHash = md5(json_encode([
'q' => $redactedQuery,
'types' => $allowedEntityTypes,
'taxonomy' => $taxonomy,
'v' => 1,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getIntent($cacheHash);
if (is_array($cached) && isset($cached['success'])) {
$cached['source'] = 'cache';
return $this->normalizeResult($cached, $allowedEntityTypes);
}
try {
$raw = $this->sendRequest($payload, $apiKey);
$parsed = $this->parseResponse($raw);
$parsed['source'] = 'openai';
system_search_cache::setIntent($cacheHash, $parsed, 3600);
return $this->normalizeResult($parsed, $allowedEntityTypes);
} catch (Throwable $e) {
return $this->failed($e->getMessage(), 'openai');
}
}
public static function redactSensitiveQuery(string $query): string
{
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
$query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query;
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
$query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query;
$query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query;
$query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query;
return $query;
}
private function resolveOpenAISettings(): array
{
if ($this->forcedEnabled !== null) {
return [(bool)$this->forcedEnabled, (string)($this->forcedApiKey ?? '')];
}
try {
$openai = new openai();
$enabled = (bool)$openai->config->enabled->getVariableValue();
$apiKey = (string)$openai->config->api_key->getVariableValue();
return [$enabled, $apiKey];
} catch (Throwable) {
return [false, ''];
}
}
private function buildPayload(string $query, array $allowedEntityTypes, array $taxonomy): array
{
$taxonomyText = json_encode([
'allowed_entity_types' => array_values($allowedEntityTypes),
'taxonomy' => $taxonomy,
], JSON_UNESCAPED_UNICODE);
$prompt = "You parse user search intent into strict JSON.\n"
. "Rules:\n"
. "- Keep output concise and valid JSON only.\n"
. "- Do not invent entity types not listed in allowed_entity_types.\n"
. "- Infer what the user is trying to find, not just literal words.\n"
. "- aliases should contain user-friendly and backend-friendly equivalent terms.\n"
. "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n"
. "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n"
. "- confidence must be between 0 and 1.\n"
. "- association_hint should be true if related records likely needed.\n\n"
. "Context:\n"
. $taxonomyText . "\n\n"
. "User query:\n"
. $query;
return [
'model' => $this->model,
'temperature' => $this->temperature,
'input' => [
[
'role' => 'user',
'content' => [
['type' => 'input_text', 'text' => $prompt],
],
],
],
'text' => [
'format' => [
'type' => 'json_schema',
'name' => 'system_search_intent',
'schema' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean'],
'normalized_query' => [
'type' => 'string',
'maxLength' => $this->maxNormalizedQueryLength,
],
'aliases' => [
'type' => 'array',
'maxItems' => $this->maxAliases,
'items' => [
'type' => 'string',
'maxLength' => $this->maxAliasLength,
],
],
'entity_hints' => [
'type' => 'array',
'maxItems' => $this->maxEntityHints,
'items' => [
'type' => 'string',
'maxLength' => $this->maxHintLength,
],
],
'confidence' => [
'type' => 'number',
'minimum' => 0,
'maximum' => 1,
],
'association_hint' => ['type' => 'boolean'],
'fallback_reason' => [
'anyOf' => [
['type' => 'string', 'maxLength' => $this->maxFallbackReasonLength],
['type' => 'null'],
],
],
],
'required' => [
'success',
'normalized_query',
'aliases',
'entity_hints',
'confidence',
'association_hint',
'fallback_reason',
],
'additionalProperties' => false,
],
'strict' => true,
],
],
];
}
private function sendRequest(array $payload, string $apiKey): array
{
if ($this->transport !== null) {
$result = call_user_func($this->transport, $payload, $apiKey);
if (!is_array($result)) {
throw new Exception('Transport returned invalid payload');
}
return $result;
}
$curl = curl_init($this->apiUrl);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
]);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$raw = curl_exec($curl);
if ($raw === false) {
$error = curl_error($curl);
curl_close($curl);
throw new Exception('cURL error: ' . $error);
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new Exception('Invalid JSON from OpenAI');
}
if ($status >= 400) {
$message = $decoded['error']['message'] ?? ('OpenAI HTTP ' . $status);
throw new Exception($message);
}
return $decoded;
}
private function parseResponse(array $response): array
{
$text = $response['output'][0]['content'][0]['text'] ?? null;
if (!is_string($text) || $text === '') {
throw new Exception('Invalid response format (missing output text)');
}
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
throw new Exception('Invalid intent JSON');
}
return $decoded;
}
private function normalizeResult(array $result, array $allowedEntityTypes = []): array
{
$normalizedQuery = trim((string)($result['normalized_query'] ?? ''));
if (mb_strlen($normalizedQuery) > $this->maxNormalizedQueryLength) {
$normalizedQuery = mb_substr($normalizedQuery, 0, $this->maxNormalizedQueryLength);
}
$aliases = $this->sanitizeStringList((array)($result['aliases'] ?? []), $this->maxAliases, $this->maxAliasLength);
$entityHints = $this->sanitizeStringList((array)($result['entity_hints'] ?? []), $this->maxEntityHints, $this->maxHintLength);
if (!empty($allowedEntityTypes)) {
$entityHints = array_values(array_intersect($allowedEntityTypes, $entityHints));
}
$fallbackReason = null;
if (isset($result['fallback_reason']) && $result['fallback_reason'] !== null) {
$fallbackReason = trim((string)$result['fallback_reason']);
if (mb_strlen($fallbackReason) > $this->maxFallbackReasonLength) {
$fallbackReason = mb_substr($fallbackReason, 0, $this->maxFallbackReasonLength);
}
}
return [
'success' => (bool)($result['success'] ?? false),
'normalized_query' => $normalizedQuery,
'aliases' => $aliases,
'entity_hints' => $entityHints,
'confidence' => max(0.0, min(1.0, (float)($result['confidence'] ?? 0.0))),
'association_hint' => (bool)($result['association_hint'] ?? false),
'fallback_reason' => $fallbackReason,
'source' => (string)($result['source'] ?? 'openai'),
];
}
private function sanitizeStringList(array $values, int $maxItems, int $maxLength): array
{
$result = [];
foreach ($values as $value) {
if (!is_string($value)) {
continue;
}
$item = trim(mb_strtolower($value));
if ($item === '') {
continue;
}
if (mb_strlen($item) > $maxLength) {
$item = mb_substr($item, 0, $maxLength);
}
if (!in_array($item, $result, true)) {
$result[] = $item;
}
if (count($result) >= $maxItems) {
break;
}
}
return $result;
}
private function failed(string $reason, string $source): array
{
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => $reason,
'source' => $source,
];
}
}
@@ -2,18 +2,18 @@
namespace classes;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_service
{
private system_search_intent_parser_i $intentParser;
private int $lowConfidenceResultThreshold = 5;
private int $lowConfidenceTopScoreThreshold = 60;
private int $defaultEntityFetchLimit = 200;
private int $defaultMaxResults = 50;
private int $maxExpandedTerms = 24;
private int $maxTermLength = 64;
private int $recencyScoreTolerance = 12;
private int $minimumEffectiveScore = 45;
private int $minimumExplicitTypeScore = 20;
private int $associationSeedScoreThreshold = 80;
private array $rankingBoostByType = [
'invoices' => 35,
'orders' => 35,
@@ -35,9 +35,8 @@ class system_search_service
private array $tableColumnsCache = [];
private array $customerContextCache = [];
public function __construct(?system_search_intent_parser_i $intentParser = null)
public function __construct()
{
$this->intentParser = $intentParser ?? new system_search_openai_intent_parser();
try {
system_search_economic_customer_index::ensureTable();
system_search_document_index::ensureTable();
@@ -59,18 +58,13 @@ class system_search_service
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
$includeAssociations = (bool)($options['include_associations'] ?? true);
$debugIntent = (bool)($options['debug_intent'] ?? false);
$limit = (int)($options['limit'] ?? 50);
$offset = (int)($options['offset'] ?? 0);
if ($limit < 1) {
$limit = 50;
$maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
if ($maxResults < 1) {
$maxResults = $this->defaultMaxResults;
}
if ($limit > 200) {
$limit = 200;
}
if ($offset < 0) {
$offset = 0;
if ($maxResults > $this->defaultMaxResults) {
$maxResults = $this->defaultMaxResults;
}
$allTypes = $this->allEntityTypes();
@@ -79,11 +73,12 @@ class system_search_service
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
}
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
$terms = $this->buildExpandedTerms($this->tokenize($query));
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
$baseMeta = [
'query' => $query,
'limit' => $limit,
'offset' => $offset,
'max_results' => $maxResults,
'allowed_types' => $activeTypes,
'cache' => ['hit' => false],
];
@@ -94,7 +89,8 @@ class system_search_service
'grouped_results' => $this->groupResultsByType([]),
'meta' => [
...$baseMeta,
'total' => 0,
'returned' => 0,
'truncated' => false,
],
];
}
@@ -104,16 +100,14 @@ class system_search_service
'include' => $includeTypes,
'exclude' => $excludeTypes,
'active' => $activeTypes,
'limit' => $limit,
'offset' => $offset,
'max' => $maxResults,
'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
'v' => 12,
'v' => 13,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getQuery($queryCacheHash);
@@ -122,7 +116,6 @@ class system_search_service
return $cached;
}
$terms = $this->buildExpandedTerms($this->tokenize($query));
$entityBoost = [];
$initialResults = $this->executeLexicalSearch(
$activeTypes,
@@ -135,76 +128,23 @@ class system_search_service
$moduleConfigVisibility,
$allowedDepartmentIds
);
$intentAssociationHint = false;
$intentMeta = [
'invoked' => false,
'source' => 'none',
'status' => 'skipped',
'confidence' => 0.0,
'expanded_terms' => $terms,
'entity_hints' => [],
'fallback_reason' => null,
];
$shouldInvokeIntent = !empty($terms) && (
$this->shouldInvokeIntentParser($initialResults)
|| $this->queryLooksIntentDriven($query, $terms)
);
if ($shouldInvokeIntent) {
$intentMeta['invoked'] = true;
$taxonomy = $this->taxonomy($activeTypes);
$intent = $this->intentParser->parse($query, $activeTypes, $taxonomy);
$intentMeta['source'] = (string)($intent['source'] ?? 'none');
$intentMeta['confidence'] = (float)($intent['confidence'] ?? 0.0);
$intentMeta['fallback_reason'] = $intent['fallback_reason'] ?? null;
$intentMeta['entity_hints'] = (array)($intent['entity_hints'] ?? []);
$intentAssociationHint = (bool)($intent['association_hint'] ?? false);
if (!empty($intent['success'])) {
$intentMeta['status'] = 'ok';
$boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? [])));
foreach ($boostedTypes as $boostedType) {
$entityBoost[$boostedType] = 25;
}
$expandedTerms = $this->buildExpandedTerms([
...$terms,
...$this->tokenize((string)($intent['normalized_query'] ?? '')),
...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))),
...$this->hintAliasTerms($boostedTypes, $taxonomy),
]);
$intentMeta['expanded_terms'] = $expandedTerms;
$initialResults = $this->executeLexicalSearch(
$activeTypes,
$expandedTerms,
$entityBoost,
$ownOnlyTypes,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds
);
} else {
$intentMeta['status'] = 'fallback';
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
if ($includeAssociations) {
$customerNumbers = [];
foreach ($initialResults as $result) {
if (!isset($result['customer_number'])) {
$customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
if ($customerNumber === null || $customerNumber <= 0) {
continue;
}
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
continue;
}
$customerNumbers[] = (int)$result['customer_number'];
$customerNumbers[] = $customerNumber;
}
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
if (count($customerNumbers) > 15) {
$customerNumbers = array_slice($customerNumbers, 0, 15);
if (count($customerNumbers) > 5) {
$customerNumbers = array_slice($customerNumbers, 0, 5);
}
if (!empty($customerNumbers)) {
$associationTypes = array_values(array_intersect(
@@ -232,18 +172,21 @@ class system_search_service
}
$item['score'] = max((int)$item['score'], 35);
}
unset($item);
$associated = $this->filterRelevantResults($associated, $includeTypes);
$initialResults = $this->mergeResults($initialResults, $associated);
}
}
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$preferRecency = $this->shouldPreferRecencySort($query, $terms);
usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
$scoreA = (int)($a['score'] ?? 0);
$scoreB = (int)($b['score'] ?? 0);
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a);
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b);
$effectiveScoreA = $this->effectiveResultScore($a);
$effectiveScoreB = $this->effectiveResultScore($b);
$recencyA = $this->resultRecencyTimestamp($a);
$recencyB = $this->resultRecencyTimestamp($b);
$cancelledA = $this->isCancelledBookingResult($a);
@@ -272,20 +215,17 @@ class system_search_service
return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
});
$total = count($initialResults);
$paged = array_slice($initialResults, $offset, $limit);
$grouped = $this->groupResultsByType($paged);
$limited = array_slice($initialResults, 0, $maxResults);
$grouped = $this->groupResultsByType($limited);
$meta = [
...$baseMeta,
'total' => $total,
'returned' => count($limited),
'truncated' => count($initialResults) > count($limited),
];
if ($debugIntent) {
$meta['intent_parser'] = $intentMeta;
}
$payload = [
'results' => $paged,
'results' => $limited,
'grouped_results' => $grouped,
'meta' => $meta,
];
@@ -294,13 +234,127 @@ class system_search_service
return $payload;
}
protected function shouldInvokeIntentParser(array $results): bool
/**
* @param array<int, string> $activeTypes
* @param array<int, string> $includeTypes
* @param array<int, string> $terms
* @return array<int, string>
*/
private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array
{
if (count($results) < $this->lowConfidenceResultThreshold) {
return true;
if (empty($activeTypes) || !empty($includeTypes)) {
return $activeTypes;
}
$topScore = (int)($results[0]['score'] ?? 0);
return $topScore < $this->lowConfidenceTopScoreThreshold;
$selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes()));
foreach ($activeTypes as $entityType) {
if (in_array($entityType, $selected, true)) {
continue;
}
if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) {
$selected[] = $entityType;
}
}
return array_values(array_intersect($activeTypes, array_values(array_unique($selected))));
}
/**
* @return array<int, string>
*/
private function defaultSearchEntityTypes(): array
{
return [
'customers',
'users',
'employees',
'orders',
'order_bookings',
'bookings',
'bookings_new',
'invoices',
'vehicles',
'departments',
'products',
'objects',
];
}
/**
* @param array<int, string> $terms
*/
private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool
{
$normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' ';
if (trim($normalizedQuery) === '') {
return false;
}
$aliases = system_search_registry::taxonomyAliases()[$entityType] ?? [];
$human = str_replace('_', ' ', $entityType);
$aliases[] = $entityType;
$aliases[] = $human;
$aliases[] = rtrim($human, 's');
$aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== '')));
foreach ($aliases as $alias) {
$aliasTerms = $this->tokenize($alias);
if (empty($aliasTerms)) {
continue;
}
if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) {
return true;
}
if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
return true;
}
$aliasText = trim(mb_strtolower($alias));
if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) {
return true;
}
}
return false;
}
/**
* @param array<int, array<string, mixed>> $results
* @param array<int, string> $includeTypes
* @return array<int, array<string, mixed>>
*/
private function filterRelevantResults(array $results, array $includeTypes): array
{
$explicitTypes = array_flip($includeTypes);
$filtered = [];
foreach ($results as $result) {
$entityType = (string)($result['entity_type'] ?? '');
$score = (int)($result['score'] ?? 0);
if ($score <= 0) {
continue;
}
if (isset($explicitTypes[$entityType])) {
if ($score >= $this->minimumExplicitTypeScore) {
$filtered[] = $result;
}
continue;
}
if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) {
$filtered[] = $result;
}
}
return $filtered;
}
private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool
{
$entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
if (!in_array($entityType, ['customers', 'users'], true)) {
return false;
}
if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) {
return false;
}
return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold;
}
/**
@@ -347,20 +401,6 @@ class system_search_service
$allowedDepartmentIds,
$forcedCustomerNumbers
);
if (empty($rows)) {
$rows = $this->searchEntity(
$entityType,
$terms,
$boost,
$ownOnly,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
} else {
$rows = $this->searchEntity(
$entityType,
@@ -1086,6 +1126,7 @@ class system_search_service
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
{
price_overrides_schema_bootstrap::ensureColumns();
$customerFilter = '';
if (!empty($forcedCustomerNumbers)) {
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
@@ -1100,11 +1141,12 @@ class system_search_service
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'u.customer_number',
'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'),
];
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
@@ -1166,6 +1208,7 @@ class system_search_service
'search_text',
'product_or_category_id',
'percentage',
'fixed_price',
'user_id',
], $terms) + $entityBoost,
'payload' => $this->augmentPayloadWithTemporal([
@@ -1173,6 +1216,7 @@ class system_search_service
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
], $row),
@@ -2643,6 +2687,11 @@ class system_search_service
return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
}
private function effectiveResultScore(array $result): int
{
return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result);
}
private function boolishTrue(mixed $value): bool
{
if (is_bool($value)) {
@@ -2702,7 +2751,7 @@ class system_search_service
return false;
}
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact intent.
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches.
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
@@ -2725,68 +2774,6 @@ class system_search_service
]);
}
/**
* @param array<int, string> $boostedTypes
* @param array<string, array<int, string>> $taxonomy
* @return array<int, string>
*/
private function hintAliasTerms(array $boostedTypes, array $taxonomy): array
{
$terms = [];
foreach ($boostedTypes as $type) {
$aliases = $taxonomy[$type] ?? [];
if (!is_array($aliases)) {
continue;
}
foreach ($aliases as $alias) {
if (!is_string($alias) || trim($alias) === '') {
continue;
}
$terms = [...$terms, ...$this->tokenize($alias)];
if (count($terms) >= 12) {
return array_slice(array_values(array_unique($terms)), 0, 12);
}
}
}
return array_slice(array_values(array_unique($terms)), 0, 12);
}
/**
* Detect natural-language style queries where intent parsing is valuable
* even when lexical score looks strong.
*
* @param array<int, string> $terms
*/
private function queryLooksIntentDriven(string $query, array $terms): bool
{
$normalized = trim(mb_strtolower($query));
if ($normalized === '' || count($terms) < 2) {
return false;
}
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
$hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1;
$hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1;
$hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1;
if ($hasStrongDomainLanguage && count($terms) >= 2) {
return true;
}
if ($hasIntentVerb && count($terms) >= 3) {
return true;
}
if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) {
return true;
}
return mb_strlen($normalized) >= 28 && count($terms) >= 4;
}
private function queryHasExplicitIdentifier(string $normalizedQuery): bool
{
if ($normalizedQuery === '') {
@@ -2981,22 +2968,6 @@ class system_search_service
return system_search_registry::allEntityTypes();
}
private function taxonomy(array $activeTypes): array
{
$aliases = system_search_registry::taxonomyAliases();
$taxonomy = [];
foreach ($activeTypes as $type) {
$resolved = $aliases[$type] ?? [];
if (empty($resolved)) {
$human = str_replace('_', ' ', $type);
$singular = rtrim($human, 's');
$resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== '')));
}
$taxonomy[$type] = $resolved;
}
return $taxonomy;
}
private function groupResultsByType(array $results): array
{
$grouped = [];
@@ -291,6 +291,12 @@ class xlvask_automation_service
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
}
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
{
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
}
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
{
$usageBag = self::productBagForAutomation($usageItems);
@@ -600,18 +606,52 @@ class xlvask_automation_service
if ($action === self::ACTION_ATTACH) {
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE;
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
}
if ($action === self::ACTION_CREATE) {
return $xlvask->config->automatic_order_creation_enabled->isTrue()
&& $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS
&& $confidence >= self::AUTO_CREATE_CONFIDENCE;
return false;
}
return false;
}
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
{
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
return false;
}
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
return false;
}
$usageItems = $context['items'] ?? [];
$orderItems = $candidateOrder['order_items'] ?? [];
return is_array($usageItems)
&& is_array($orderItems)
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
}
private function candidateOrderFromSuggestion(array $suggestion): ?array
{
$candidateOrder = $suggestion['candidate_order'] ?? null;
if (is_array($candidateOrder)) {
return $candidateOrder;
}
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
return null;
}
$decoded = json_decode($candidateOrderJson, true);
return is_array($decoded) ? $decoded : null;
}
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
{
try {
@@ -646,7 +686,7 @@ class xlvask_automation_service
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
if ($automatic) {
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.');
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
}
return $this->formatSuggestion($latest);
@@ -660,6 +700,15 @@ class xlvask_automation_service
}
}
private function automaticFeedbackReason(array $suggestion, array $context): string
{
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
return 'Automatisk accepteret: Prisoverensstemmelse.';
}
return 'Automatisk accepteret.';
}
private function createOrderFromContext(array $context): orders_o
{
$orderData = $context['proposed_order'];
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
{
global $db;
(new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = [
'FinishStatus = 1',
'(ignored_at IS NULL OR ignored_at = "")',
];
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
} else {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
}
if ($dateTo !== null && strtotime($dateTo) !== false) {
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
}
$limit = max(1, min(500, $limit));
+5 -2
View File
@@ -39,8 +39,11 @@ $htaccess = "
# php -- END cPanel-generated handler, do not edit
";
// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.)
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
// Keep the compatibility rewrite file stable without rewriting it every minute.
$htaccessPath = __DIR__ . '/.htaccess';
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
file_put_contents($htaccessPath, $htaccess);
}
// Log the time of the cron job
file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
+293 -13
View File
@@ -8,6 +8,7 @@ use classes\invoice_period_flag_service;
use classes\coolify_manager;
use classes\replication_manager;
use classes\redis;
use classes\selfserve;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
@@ -40,6 +41,11 @@ const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
require_once __DIR__ . '/../classes/cron_schedule.php';
require_once __DIR__ . '/../classes/cron_task_definition.php';
require_once __DIR__ . '/../classes/cron_task_registry.php';
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
require_once __DIR__ . '/../classes/cron_scheduler.php';
if (!defined('WD')) {
exit;
@@ -652,7 +658,6 @@ function SystemSearchCacheMaintenanceCron(): void
if ($rebuildRequest !== null) {
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$scope = (string)($rebuildRequest['scope'] ?? 'all');
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
if ($scope === 'types' && !empty($types)) {
@@ -1470,17 +1475,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
}
}
foreach ( $cron_tasks as $task => $data ) {
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
$nextRun = $lastRun + $data['interval'];
$cron_tasks[$task]['last_run'] = $lastRun;
$cron_tasks[$task]['next_run'] = $nextRun;
if ($nextRun <= time()) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
$data['function']();
redis->set_last_crond_run($task, time());
echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n";
} else {
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
function SelfserveOpeningRelayActivationCron(): array
{
global $db;
if (!($db instanceof \classes\db)) {
warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
return [
'skipped' => true,
'reason' => 'database_unavailable',
];
}
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'));
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
$summary = [
'checked_departments' => count($candidates),
'activated_departments' => 0,
'skipped_departments' => 0,
'failed_departments' => 0,
'activated_relays' => 0,
'skipped_relays' => 0,
'failed_relays' => 0,
];
foreach ($candidates as $candidate) {
$departmentId = (int)($candidate['department_id'] ?? 0);
$opensAt = (string)($candidate['opens_at'] ?? '');
if ($departmentId <= 0 || $opensAt === '') {
$summary['skipped_departments']++;
continue;
}
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
$summary['skipped_departments']++;
continue;
}
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
$summary['activated_relays'] += (int)$departmentSummary['activated'];
$summary['skipped_relays'] += (int)$departmentSummary['skipped'];
$summary['failed_relays'] += (int)$departmentSummary['failed'];
if ((int)$departmentSummary['failed'] > 0) {
$summary['failed_departments']++;
continue;
}
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
if ((int)$departmentSummary['activated'] > 0) {
$summary['activated_departments']++;
} else {
$summary['skipped_departments']++;
}
}
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
. $summary['activated_relays'] . " cleaner relays activated across "
. $summary['activated_departments'] . " departments.\n";
return $summary;
}
function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array
{
global $db;
$weekday = strtolower($now->format('l'));
$allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
if (!in_array($weekday, $allowedWeekdays, true)) {
return [];
}
$startColumn = $weekday . '_start';
$endColumn = $weekday . '_end';
$sql = "
SELECT
oh.department AS department_id,
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
FROM department_time_bookings_opening_hours oh
INNER JOIN department_variables dv ON dv.department_id = oh.department
INNER JOIN department_lanes dl ON dl.department = oh.department
WHERE dv.variable = 'selfserve_enabled'
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
AND oh.`$startColumn` IS NOT NULL
AND oh.`$endColumn` IS NOT NULL
AND dl.deleted_at IS NULL
AND COALESCE(dl.selfserve_enabled, 1) = 1
AND dl.relay_machine_cleaner_id IS NOT NULL
AND TRIM(dl.relay_machine_cleaner_id) <> ''
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
return array_values(array_filter(
$db->fetch_all($result),
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
$now,
(string)($row['opens_at'] ?? ''),
(string)($row['closes_at'] ?? '')
)
));
}
function selfserveOpeningCleanerRelayWindowActive(
DateTimeImmutable $now,
?string $opensAt,
?string $closesAt
): bool {
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) {
return false;
}
$nowSeconds = ((int)$now->format('G') * 3600)
+ ((int)$now->format('i') * 60)
+ (int)$now->format('s');
if ($opensAtSeconds < $closesAtSeconds) {
return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds;
}
return $nowSeconds >= $opensAtSeconds;
}
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
{
$time = trim((string)$time);
if ($time === '') {
return null;
}
if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
return null;
}
$hours = (int)$matches[1];
$minutes = (int)$matches[2];
$seconds = isset($matches[3]) ? (int)$matches[3] : 0;
if ($hours > 23 || $minutes > 59 || $seconds > 59) {
return null;
}
return ($hours * 3600) + ($minutes * 60) + $seconds;
}
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array
{
$summary = [
'activated' => 0,
'skipped' => 0,
'failed' => 0,
];
$selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
foreach ($lanes as $departmentLane) {
if (!($departmentLane instanceof department_lanes_o)) {
$summary['skipped']++;
continue;
}
try {
if (!$departmentLane->isSelfServeEnabled()) {
$summary['skipped']++;
continue;
}
} catch (Throwable) {
$summary['skipped']++;
continue;
}
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
$summary['skipped']++;
continue;
}
$laneId = (int)$departmentLane->id;
if ($laneId <= 0) {
$summary['skipped']++;
continue;
}
try {
$lane = $selfserve->lane($laneId);
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
$summary['skipped']++;
continue;
}
$lane->setMachineCleanerRelayStatusHard(true);
$summary['activated']++;
} catch (Throwable $throwable) {
$summary['failed']++;
warn(
'SelfserveOpeningRelayActivationCron failed for department '
. $departmentId
. ', lane '
. $laneId
. ': '
. $throwable->getMessage()
);
}
}
return $summary;
}
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
{
if (
$departmentLane === null
|| !isset($departmentLane->relay_machine_cleaner_id)
|| !is_object($departmentLane->relay_machine_cleaner_id)
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
) {
return false;
}
try {
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
} catch (Throwable) {
return false;
}
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
}
function selfserveOpeningCleanerRelayActivationKey(
int $departmentId,
string $opensAt,
DateTimeImmutable $now
): string {
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
return 'selfserve:opening-cleaner-relays:'
. $now->format('Y-m-d')
. ':'
. $departmentId
. ':'
. $normalizedOpensAt;
}
function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool
{
if (!defined('redis')) {
return false;
}
try {
return redis->exists($cacheKey);
} catch (Throwable) {
return false;
}
}
function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void
{
if (!defined('redis')) {
return;
}
try {
redis->setEx($cacheKey, '1', 36 * 3600);
} catch (Throwable) {
// Redis idempotency should not block relay activation.
}
}
if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) {
return;
}
try {
$response_cron = (new \classes\cron_scheduler())->runDue('automatic');
} catch (Throwable $throwable) {
warn('Cron scheduler failed: ' . $throwable->getMessage());
$response_cron = [
'ran' => [],
'count' => 0,
'error' => $throwable->getMessage(),
];
}
@@ -1,26 +0,0 @@
<?php
namespace interfaces;
interface system_search_intent_parser_i
{
/**
* Parse a natural-language search query into structured search hints.
*
* @param string $query The raw user query.
* @param array $allowedEntityTypes Entity types the caller is allowed to search.
* @param array $taxonomy Public taxonomy/aliases to improve intent parsing.
* @return array{
* success: bool,
* normalized_query: string,
* aliases: array<int, string>,
* entity_hints: array<int, string>,
* confidence: float,
* association_hint: bool,
* fallback_reason: string|null,
* source: string
* }
*/
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array;
}
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'backups.create_backup',
'legacy_name' => 'backup',
'name' => 'Create backup',
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
'module' => 'backups',
'handler' => 'backup',
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 60000,
'priority' => 115,
],
];
@@ -131,7 +131,7 @@
"post": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate",
"description": "Requires permission `download_own_wash_certificate`. Returns a presigned download link if certificate exists and user has access.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Returns a presigned download link if certificate exists and user has access.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -146,7 +146,7 @@
"get": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate PDF",
"description": "Requires permission `download_own_wash_certificate`. Checks both legacy and current storage buckets.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Checks both legacy and current storage buckets.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -0,0 +1,28 @@
<?php
return [
[
'id' => 'coolify.availability_monitor',
'legacy_name' => 'CoolifyAvailabilityMonitorCron',
'name' => 'Coolify availability monitor',
'description' => 'Checks registered Coolify-managed infrastructure targets.',
'module' => 'coolify',
'handler' => 'CoolifyAvailabilityMonitorCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 2000,
'priority' => 25,
],
[
'id' => 'coolify.load_balancer_reconcile',
'legacy_name' => 'CoolifyLoadBalancerReconcileCron',
'name' => 'Coolify load balancer reconcile',
'description' => 'Reconciles public gateway load balancer targets when automation is enabled.',
'module' => 'coolify',
'handler' => 'CoolifyLoadBalancerReconcileCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 26,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'dynamicimages.pre_render',
'legacy_name' => 'PreRenderDynamicImagesCron',
'name' => 'Pre-render dynamic images',
'description' => 'Pre-renders common self-serve dynamic image variants into Redis.',
'module' => 'dynamicimages',
'handler' => 'PreRenderDynamicImagesCron',
'schedule' => ['type' => 'interval', 'seconds' => 900],
'timeout_seconds' => 900,
'estimated_duration_ms' => 30000,
'priority' => 90,
],
];
@@ -0,0 +1,64 @@
<?php
return [
[
'id' => 'economic.sync_user_customer_discounts',
'legacy_name' => 'SyncUserEconomicCustomerDiscounts',
'name' => 'Sync e-conomic customer discounts',
'description' => 'Clears cached customer discounts so e-conomic discount data can refresh.',
'module' => 'economic',
'handler' => 'SyncUserEconomicCustomerDiscounts',
'schedule' => ['type' => 'interval', 'seconds' => 180],
'timeout_seconds' => 300,
'estimated_duration_ms' => 2000,
'priority' => 30,
],
[
'id' => 'economic.sync_user_customer_details',
'legacy_name' => 'SyncUserEconomicCustomerDetails',
'name' => 'Sync e-conomic customer details',
'description' => 'Refreshes customer details and search documents from e-conomic.',
'module' => 'economic',
'handler' => 'SyncUserEconomicCustomerDetails',
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 30000,
'priority' => 110,
],
[
'id' => 'economic.sync_system_search_customer_index',
'legacy_name' => 'SyncSystemSearchEconomicCustomerIndex',
'name' => 'Sync e-conomic customer search index',
'description' => 'Refreshes the system search e-conomic customer index.',
'module' => 'economic',
'handler' => 'SyncSystemSearchEconomicCustomerIndex',
'schedule' => ['type' => 'interval', 'seconds' => 900],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 75,
],
[
'id' => 'economic.sync_invoice_status',
'legacy_name' => 'SyncEconomicInvoiceStatus',
'name' => 'Sync e-conomic invoice status',
'description' => 'Checks e-conomic invoice errors and draft status.',
'module' => 'economic',
'handler' => 'SyncEconomicInvoiceStatus',
'schedule' => ['type' => 'interval', 'seconds' => 120],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 35,
],
[
'id' => 'economic.transfer_queue',
'legacy_name' => 'EconomicTransferQueueCron',
'name' => 'Process e-conomic transfer queue',
'description' => 'Processes pending e-conomic transfer queue jobs in bounded batches.',
'module' => 'economic',
'handler' => 'EconomicTransferQueueCron',
'schedule' => ['type' => 'interval', 'seconds' => 30],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 20,
],
];
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
* @throws Exception If the request fails
*/
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
{
$this->add_orders($invoiceDraftId, [$order], $currency);
}
/**
* Add many orders to a draft invoice and flush their lines in batches.
*
* @param orders_o[] $orders
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
{
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() > 0) {
$orders_with_invoice_lines = 0;
foreach ( $orders as $order ) {
if (!$order instanceof orders_o) {
throw new Exception('Order payload must contain orders_o instances');
}
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() <= 0) {
continue;
}
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order);
// Add the order lines
$draftInvoice->addOrderItemLines($order);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
// Save the draft invoice lines
$draftInvoice->addLines();
}
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
return [
'order_count' => count($orders),
'orders_with_invoice_lines' => $orders_with_invoice_lines,
...$metrics,
];
}
/**
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
$draft_invoice->addLines();
}
}
}
}
@@ -10,6 +10,8 @@ use objects\orders_o;
class economic_invoice_draft
{
public const DEFAULT_LINE_BATCH_SIZE = 500;
/**
* The Economic draftInvoiceNumber
* @var int $draft_invoice_number
@@ -110,13 +112,55 @@ class economic_invoice_draft
}
/**
* Add the lines to the draft invoice
* @return void
* Add the lines to the draft invoice.
*/
public function addLines(): void
{
$this->flushLinesInBatches();
}
/**
* Add queued draft lines using chunked requests.
*
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
*/
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
{
$lines = array_values($this->draft_lines);
$line_count = count($lines);
if ($line_count === 0) {
return [
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
];
}
$batch_size = max(1, $batch_size);
$batch_sizes = [];
foreach (array_chunk($lines, $batch_size) as $batch) {
$this->sendDraftLines($batch);
$batch_sizes[] = count($batch);
}
$this->draft_lines = [];
return [
'line_count' => $line_count,
'batch_count' => count($batch_sizes),
'batch_sizes' => $batch_sizes,
];
}
public function pendingLineCount(): int
{
return count($this->draft_lines);
}
protected function sendDraftLines(array $draft_lines): object
{
$economic = new economic();
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
}
/**
@@ -799,7 +799,6 @@ class edge_gateway_manager
$existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'relay_id' => $relayId,
'deleted_at' => null,
], ['id']);
if ($existing !== []) {
@@ -818,6 +817,7 @@ class edge_gateway_manager
$bindingObject->approved_by->set($userId);
$bindingObject->approved_at->set($this->now());
$bindingObject->metadata_json->set($bindingMetadata);
$bindingObject->deleted_at->set(null);
continue;
}
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'failover.replica_monitor',
'legacy_name' => 'ReplicaFailoverMonitorCron',
'name' => 'Replica failover monitor',
'description' => 'Checks replicated services and promotes eligible replicas during automatic failover.',
'module' => 'failover',
'handler' => 'ReplicaFailoverMonitorCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 3000,
'priority' => 24,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'goals.progress_alerts',
'legacy_name' => 'GoalsProgressAlertsCron',
'name' => 'Goals progress alerts',
'description' => 'Evaluates department goal alert schedules and sends due progress alerts.',
'module' => 'goals',
'handler' => 'GoalsProgressAlertsCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 600,
'estimated_duration_ms' => 10000,
'priority' => 45,
],
];
@@ -440,6 +440,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return null;
}
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
if (!$session->markCompletedIfOpen($orderId)) {
return $this->getSessionSummary((int)$session->id);
}
@@ -456,6 +458,25 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id);
}
protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void
{
try {
if ($session->wash_started_at->value() !== null) {
return;
}
$lane = (new selfserve())->lane($laneId);
$washStartedAt = (int)$lane->getWashStartTime();
if ($washStartedAt <= 0) {
return;
}
$session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt));
} catch (\Throwable) {
// Session timestamp enrichment must not block STOP completion.
}
}
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
{
$lane = (new selfserve())->lane($laneId);
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'selfserve.activate_opening_cleaner_relays',
'legacy_name' => 'SelfserveOpeningRelayActivationCron',
'name' => 'Activate self-serve opening cleaner relays',
'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.',
'module' => 'selfserve',
'handler' => 'SelfserveOpeningRelayActivationCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 45,
],
];
@@ -0,0 +1,64 @@
<?php
return [
[
'id' => 'system.sync_logs',
'legacy_name' => 'SyncLogs',
'name' => 'Sync logs',
'description' => 'Flush Redis-backed application logs into the database.',
'module' => 'system',
'handler' => 'syncLogsToDatabase',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 120,
'estimated_duration_ms' => 1000,
'priority' => 10,
],
[
'id' => 'system.search_cache_maintenance',
'legacy_name' => 'SystemSearchCacheMaintenanceCron',
'name' => 'System search cache maintenance',
'description' => 'Processes dirty-table and full rebuild requests for system search caches.',
'module' => 'system',
'handler' => 'SystemSearchCacheMaintenanceCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 600,
'estimated_duration_ms' => 3000,
'priority' => 70,
],
[
'id' => 'system.prune_session_activity',
'legacy_name' => 'PruneSystemSessionActivityCron',
'name' => 'Prune system session activity',
'description' => 'Deletes stale system session activity rows.',
'module' => 'system',
'handler' => 'PruneSystemSessionActivityCron',
'schedule' => ['type' => 'interval', 'seconds' => 86400],
'timeout_seconds' => 300,
'estimated_duration_ms' => 1000,
'priority' => 120,
],
[
'id' => 'system.invoice_flags_manual_cache',
'legacy_name' => 'WarmInvoicePeriodManualFlagsCron',
'name' => 'Warm manual invoice flag cache',
'description' => 'Warms superuser invoice-period manual flag counters.',
'module' => 'system',
'handler' => 'WarmInvoicePeriodManualFlagsCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 600,
'estimated_duration_ms' => 2000,
'priority' => 80,
],
[
'id' => 'system.invoice_flags_automatic_cache',
'legacy_name' => 'WarmInvoicePeriodAutomaticFlagsCron',
'name' => 'Warm automatic invoice flag cache',
'description' => 'Warms current, previous, and queued invoice-period automatic flag caches.',
'module' => 'system',
'handler' => 'WarmInvoicePeriodAutomaticFlagsCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 900,
'estimated_duration_ms' => 5000,
'priority' => 85,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'weatherapi.preload_department_responses',
'legacy_name' => 'PreloadDepartmentWeatherResponsesCron',
'name' => 'Preload department weather responses',
'description' => 'Warms department weather timeline responses for visible and active departments.',
'module' => 'weatherapi',
'handler' => 'PreloadDepartmentWeatherResponsesCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 40,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'workfeed.warm_employee_names',
'legacy_name' => 'WarmWorkfeedEmployeeNamesCron',
'name' => 'Warm Workfeed employee names',
'description' => 'Warms Redis mappings for Workfeed employee identifiers and display names.',
'module' => 'workfeed',
'handler' => 'WarmWorkfeedEmployeeNamesCron',
'schedule' => ['type' => 'interval', 'seconds' => 21600],
'timeout_seconds' => 600,
'estimated_duration_ms' => 5000,
'priority' => 100,
],
];
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'xlvask.sync_module',
'legacy_name' => 'SyncXLVaskModuleCron',
'name' => 'Sync XL Vask module',
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
'module' => 'xlvask',
'handler' => 'SyncXLVaskModuleCron',
'schedule' => ['type' => 'interval', 'seconds' => 3600],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 95,
],
];
@@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper
private function unsetNullifiableProperties(): void
{
$nullable_review_metadata = [
'ignored_at',
'ignored_reason',
];
// Unset properties that are null or empty strings
$properties = [
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
if ($this->isEmptyOrDefault($this->{$property})) {
$tmp_value = $this->{$property};
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) {
$this->{$property} = ''; // Set to null if it matches the default string
$this->{$property} = (
$tmp_value === $this->default_string_nullable
&& in_array($property, $nullable_review_metadata, true)
) ? null : '';
} elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
if ($tmp_value === $this->default_int_nullable) {
$this->{$property} = null; // Set to null if it matches the default int nullable
@@ -37,6 +37,7 @@ class collected_order_invoices_o extends db
public object_property $updated_at;
public object_property $closed_at;
public int $economic_wash_subscription_user_id = 1857;
private ?array $last_economic_transfer_metrics = null;
/**
* The processor types
*
@@ -712,6 +713,7 @@ class collected_order_invoices_o extends db
*/
public function addInvoicesToDraft(bool $skip_check = false): self
{
$this->last_economic_transfer_metrics = null;
// Require the invoice collection to be selected
self::requireSelected();
// Require the invoice collection to be open
@@ -736,10 +738,20 @@ class collected_order_invoices_o extends db
usort($orders, function ($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
// Add the invoices to the invoice draft
// Add the invoice lines to the draft in one accumulated batch path.
$order_objects = [];
foreach ( $orders as $order ) {
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
$order_object = new orders_o();
$order_object->select((int)$order['id']);
$order_object->requireSelected();
$order_objects[] = $order_object;
}
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
...$metrics,
];
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
// Object changed
@@ -748,6 +760,11 @@ class collected_order_invoices_o extends db
return $this;
}
public function getLastEconomicTransferMetrics(): ?array
{
return $this->last_economic_transfer_metrics;
}
/**
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
* @param int $draft_id The invoice draft id
@@ -490,18 +490,19 @@ class customer_vehicles_o extends db
return [];
}
//print_r($transaction_ids);
// Convert the array of transaction ids to a comma separated string
$orders = '';
$orders = [];
foreach ($transaction_ids as $transaction_id) {
// Check if the transaction is included in the invoicing.
$tmp = (new orders_o())->select((int)$transaction_id);
if (!$tmp->isIncludedInInvoicing()) {
continue; // The transaction is not included in the invoicing, skip it
}
$orders .= (int)$transaction_id . ',';
$orders[] = (int)$transaction_id;
}
// Remove the last comma
$orders = rtrim($orders, ',');
if (empty($orders)) {
return [];
}
$order_ids = implode(',', $orders);
// Get the first two transactions that are not deleted and contains at least one order item with the 'product_id' of the vehicle type for the vehicle
$query = "
SELECT o.id
@@ -509,7 +510,7 @@ class customer_vehicles_o extends db
JOIN order_items oi ON oi.order_id = o.id
WHERE o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND o.id IN ($orders)
AND o.id IN ($order_ids)
AND oi.product_id = " . (int)$this->type->value() . "
GROUP BY o.id
ORDER BY o.created_at ASC
@@ -0,0 +1,114 @@
<?php
namespace objects;
use classes\db;
use classes\department_customer_price_overrides_schema_bootstrap;
use traits\db_object_t;
class department_customer_price_overrides_o extends db
{
use db_object_t;
public function structure(): void
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->setTable('department_customer_price_overrides');
}
public function objectChanged(): void
{
}
public function setPrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId, int $percentage, ?int $fixedPrice = null): void
{
global $db;
if ($isCategory) {
$fixedPrice = null;
}
$this->removePrice($departmentId, $userId, $isCategory, $objectId);
if ($percentage <= 0 && $fixedPrice === null) {
return;
}
$objectId = $db->escape_string((string)$objectId);
$fixedPriceSql = $fixedPrice === null ? 'NULL' : (string)max(0, (int)$fixedPrice);
$sql = "INSERT INTO {$this->table} (`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
VALUES (" . (int)$departmentId . ", " . (int)$userId . ", " . (int)$isCategory . ", '{$objectId}', " . (int)$percentage . ", {$fixedPriceSql})";
$db->query($sql);
}
public function removePrice(int $departmentId, int $userId, bool $isCategory, int|string $objectId): void
{
global $db;
$objectId = $db->escape_string((string)$objectId);
$sql = "DELETE FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
AND `is_category` = " . (int)$isCategory . "
AND `product_or_category_id` = '{$objectId}'";
$db->query($sql);
}
/**
* @return array<int, array<string, mixed>>
*/
public function getAllPrices(int $departmentId, int $userId): array
{
global $db;
$sql = "SELECT * FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
ORDER BY `is_category` DESC, `product_or_category_id` ASC";
$result = $db->query($sql);
$prices = [];
if ($result && $result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$prices[] = $this->parseRow($row);
}
}
return $prices;
}
public function getDirectPriceRow(int $departmentId, int $userId, bool $isCategory, int|string $objectId): ?array
{
global $db;
$objectId = $db->escape_string((string)$objectId);
$sql = "SELECT * FROM {$this->table}
WHERE `department_id` = " . (int)$departmentId . "
AND `user_id` = " . (int)$userId . "
AND `is_category` = " . (int)$isCategory . "
AND `product_or_category_id` = '{$objectId}'
LIMIT 1";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
return $this->parseRow($result->fetch_assoc());
}
private function parseRow(array $row): array
{
$isCategory = (bool)$row['is_category'];
return [
'id' => (int)$row['id'],
'department_id' => (int)$row['department_id'],
'user_id' => (int)$row['user_id'],
'is_category' => $isCategory,
'product_or_category_id' => $isCategory ? (string)$row['product_or_category_id'] : (int)$row['product_or_category_id'],
'percentage' => (int)$row['percentage'],
'fixed_price' => array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null,
'created_at' => (string)$row['created_at'],
'updated_at' => (string)$row['updated_at'],
];
}
}
@@ -2,7 +2,10 @@
namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use classes\db;
use classes\department_wash_count_service;
use classes\object_property;
use Exception;
use traits\db_object_t;
@@ -510,8 +513,6 @@ class department_daily_reports_o extends db
public function getTransactionsOnDateWashesCount(string $date, int $department_id, string $date_to = null): int
{
return self::methodCacheWithParameters(__METHOD__, func_get_args(), 120, function() use ($date, $department_id, $date_to) {
global /** @var db $db */
$db;
// If the date_to is null, set it to the date
if ($date_to === null) {
$date_to = $date; // Making the report for an entire day
@@ -519,33 +520,7 @@ class department_daily_reports_o extends db
// Set the date time to cover the entire day
$date = date('Y-m-d 00:00:00', strtotime($date));
$date_to = date('Y-m-d 23:59:59', strtotime($date_to));
$conn = $db->conn();
// Get all the product prices, in all the orders (Not counting removed orders), for the given date and department, and sum them
$stmt = $conn->prepare(
'SELECT COUNT(DISTINCT o.id) as amount FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.department_id = ? AND DATE(o.created_at) BETWEEN ? AND ? AND o.deleted_at IS NULL AND oi.deleted_at IS NULL AND p.is_wash = 1'
);
if ($stmt) {
$stmt->bind_param('iss', $department_id, $date, $date_to); // Bind parameters (i = integer, s = string)
$stmt->execute();
$result = $stmt->get_result(); // Get the result set from the statement
$data = $result->fetch_assoc(); // Fetch the result as an associative array
// Access the "amount" field
if (!$data) {
// If there are no orders, set the amount to 0
$amount = 0;
} else {
$amount = $data['amount'];
}
$stmt->close(); // Close the statement
} else {
// Handle query preparation error
die('Query preparation failed: ' . $conn->error);
}
return (int)$amount;
return (new department_wash_count_service())->countInDateRange($date, $date_to, $department_id);
});
}
@@ -616,9 +591,6 @@ class department_daily_reports_o extends db
*/
public function getTransactionSummaryForDepartments(string $date, array $department_ids, string $date_to = null): array
{
global /** @var db $db */
$db;
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
if ($normalized_department_ids === []) {
return [
@@ -631,30 +603,17 @@ class department_daily_reports_o extends db
}
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
COALESCE(SUM(oi.quantity), 0) AS products,
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings,
COUNT(DISTINCT CASE WHEN p.is_wash = 1 THEN o.id END) AS washes
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL";
$result = $db->query($sql);
$row = is_object($result) ? $result->fetch_assoc() : null;
$transaction_summary = (new department_wash_count_service())->transactionSummary(
$date_start,
$date_end,
$normalized_department_ids
);
return [
'quantity' => (int)($row['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
'washes' => (int)($row['washes'] ?? 0),
'quantity' => (int)($transaction_summary['quantity'] ?? 0),
'products' => (int)($transaction_summary['products'] ?? 0),
'earnings' => (int)($transaction_summary['earnings'] ?? 0),
'washes' => (int)($transaction_summary['washes'] ?? 0),
'water_usage' => $this->getWaterUsageForDepartments($date, $normalized_department_ids, $date_to),
];
}
@@ -758,45 +717,13 @@ class department_daily_reports_o extends db
*/
public function getWashTransactionsForDepartments(string $date, array $department_ids, string $date_to = null): array
{
global /** @var db $db */
$db;
$normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
[$date_start, $date_end] = $this->resolveDateRange($date, $date_to);
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT DISTINCT o.id, o.department_id, o.created_at
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND p.is_wash = 1
ORDER BY o.created_at ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'department_id' => (int)($row['department_id'] ?? 0),
'created_at' => (string)($row['created_at'] ?? ''),
];
}
return $rows;
return (new department_wash_count_service())->listTransactions($date_start, $date_end, $normalized_department_ids);
}
/**
@@ -22,6 +22,7 @@ class departments_o extends db
public object_property $dimension; // The dimension of the department
public object_property $visible; // The visibility of the department
public object_property $archived; // Whether the department is archived
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
public object_property $branding; // The branding of the department
public object_property $longitude; // The longitude of the department (Can be null)
public object_property $latitude; // The latitude of the department (Can be null)
@@ -107,6 +108,7 @@ class departments_o extends db
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
@@ -185,6 +187,12 @@ class departments_o extends db
return $department;
}
public function isCustomPricingOnly(int $department_id): bool
{
$department = $this->getDepartmentById($department_id);
return (bool)(int)($department['custom_pricing_only'] ?? 0);
}
/**
* Get the price of a product in a department
* @param int $department_id
@@ -381,7 +381,7 @@ class order_bookings_o extends db
}
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
if ($order->hasWashCertificateAttached()) {
if ($this->getOrder()->hasWashCertificateAttached()) {
$this->sendWashCertificateToCustomer();
}
}
@@ -585,7 +585,7 @@ class order_bookings_o extends db
return !empty($this->order_id->value());
}
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, string $date = null): int
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, ?string $date = null): int
{
global /** @var db $db */
$db;
+10 -6
View File
@@ -3,6 +3,7 @@
namespace objects;
use classes\db;
use classes\customer_order_product_policy;
use classes\object_property;
use Exception;
use traits\db_object_t;
@@ -93,6 +94,7 @@ class order_items_o extends db
{
global $db, $response;
try {
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
// Avoid SQL injection
$reference = $db->escape_string($reference);
$notes = $db->escape_string($notes);
@@ -167,18 +169,20 @@ class order_items_o extends db
try {
// Get the order
$order = (new orders_o())->getOrderById($order_id);
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
// Get the product price
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
$product = (new products_o())->getProductById($product_id);
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
$price = $priceResolution['price'];
// Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id);
$discount = $customer->getCustomPrice($product_id, false);
if ($discount) {
$price = $price - ($price * $discount / 100);
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false, (int)$order->department_id->value());
}
// If the price is forced, set the price to the forced price
if ($forcePrice) {
if ($forcePrice !== null) {
$price = (int)$forcePrice;
}
@@ -354,4 +358,4 @@ class order_items_o extends db
{
return (new products_o())->select((int)$this->product_id->value());
}
}
}
+22 -91
View File
@@ -2,8 +2,11 @@
namespace objects;
require_once WD . '/classes/department_wash_count_service.php';
use attachments\helpers\attachment_content;
use classes\db;
use classes\department_wash_count_service;
use classes\email;
use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap;
@@ -1428,15 +1431,16 @@ class orders_o extends db
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
$order_item->reference->set('');
// Get the product price based on the department
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$product_price = (int)$priceResolution['price']; // Get the department price for the product
// Get the customers custom price discount percentage
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
if (!$user->exists()) {
throw new Exception('No user found matching the customer number in the usage log');
}
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price, true, (int)$this->department_id->value());
}
$order_item->notes->set(null); // Set notes for the simulated order item
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
@@ -1503,11 +1507,12 @@ class orders_o extends db
if (!$current_user->exists()) {
throw new Exception('No current user found');
}
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
// Apply the discount percentage to the product price
return (int)round($price * (1 - ($discount_percentage / 100)));
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$price = (int)$priceResolution['price']; // Get the department price for the product
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price;
}
return $current_user->applyProductCustomerPricing((int)$product->id, $price, true, (int)$this->department_id->value());
}
/**
@@ -1589,13 +1594,16 @@ class orders_o extends db
$product_id = (int)$item['product_id'];
if (!isset($department_price_cache[$product_id])) {
$product = (new products_o())->select($product_id);
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
}
if ($tmp_user === null) {
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
}
$discount = $tmp_user->getCustomPrice($product_id, false);
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
$unitPrice = (int)$department_price_cache[$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false, $department_id);
}
$post_discount = $unitPrice * $quantity;
$total += $post_discount;
}
@@ -2000,33 +2008,7 @@ class orders_o extends db
public function countWashesInDateRange(string $date_start, string $date_end, int $department_id): int
{
global /** @var db $db */
$db;
// Validate the date range
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
// Prepare the SQL query to count washes in the date range for the department
$date_start = $db->escape_string($date_start);
$date_end = $db->escape_string($date_end);
// Get the amount of orders with at least one order item that has a product with the is_wash column set to true
$sql = "SELECT COUNT(DISTINCT o.id) AS wash_count
FROM $this->table o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id = $department_id
AND o.created_at BETWEEN '$date_start' AND '$date_end'
AND o.deleted_at IS NULL
AND p.is_wash = 1";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return 0; // No washes found in the date range
}
$row = $result->fetch_assoc();
return (int)$row['wash_count'];
return (new department_wash_count_service())->countInDateRange($date_start, $date_end, $department_id);
}
/**
@@ -2036,58 +2018,7 @@ class orders_o extends db
*/
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global /** @var db $db */
$db;
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
$normalized_department_ids = [];
foreach ($department_ids as $department_id) {
$normalized_id = (int)$department_id;
if ($normalized_id > 0) {
$normalized_department_ids[$normalized_id] = true;
}
}
if ($normalized_department_ids === []) {
return [];
}
$department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids)));
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT o.department_id,
DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(DISTINCT o.id) AS wash_count
FROM $this->table o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND p.is_wash = 1
GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
return (new department_wash_count_service())->countByHourForDepartments($date_start, $date_end, $department_ids);
}
/**
+66 -16
View File
@@ -13,6 +13,11 @@ class products_o extends db
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
public const PRICE_SOURCE_DEPARTMENT = 'department';
public const PRICE_SOURCE_DEFAULT = 'default';
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
public const PRICE_SOURCE_KEY = '_department_price_source';
/**
* The name of the product
@@ -255,24 +260,41 @@ class products_o extends db
* @param int $department_id
* @return array
*/
public function applyDepartmentPricing(array $products, int $department_id): array
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
{
global $db;
$department_id = $db->escape_string($department_id);
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
$result = $db->query($sql);
$prices = $db->fetch_all($result);
$priceLookup = [];
foreach ($prices as $price) {
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
}
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
foreach ( $products as $key => $product ) {
foreach ( $prices as $price ) {
if ((int)$product['id'] === (int)$price['product_id']) {
$products[$key]['price'] = $price['price'];
}
$productId = (int)($product['id'] ?? 0);
$source = self::PRICE_SOURCE_DEFAULT;
if (array_key_exists($productId, $priceLookup)) {
$products[$key]['price'] = $priceLookup[$productId];
$source = self::PRICE_SOURCE_DEPARTMENT;
} elseif ($customPricingOnly) {
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
}
if ($includePriceSource) {
$products[$key][self::PRICE_SOURCE_KEY] = $source;
}
}
return $products;
}
public function getDepartmentPrice(int $department_id): int
/**
* @return array{price:int,source:string}
*/
public function getDepartmentPriceResolution(int $department_id): array
{
global $db;
$department_id = $db->escape_string($department_id);
@@ -281,34 +303,62 @@ class products_o extends db
$prices = $db->fetch_all($result);
// Check if the product has a department price
if (count($prices) > 0) {
return $prices[0]['price'];
return [
'price' => (int)$prices[0]['price'],
'source' => self::PRICE_SOURCE_DEPARTMENT,
];
}
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
return [
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
];
}
// Return the default price
return $this->price->value();
return [
'price' => (int)$this->price->value(),
'source' => self::PRICE_SOURCE_DEFAULT,
];
}
public function applyCustomerDiscounts(array $products, users_o $customer): array
public function getDepartmentPrice(int $department_id): int
{
return $this->getDepartmentPriceResolution($department_id)['price'];
}
public function applyCustomerDiscounts(array $products, users_o $customer, ?int $departmentId = null): array
{
global $db;
// Get the customer's discounts
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer), $products);
return array_map(fn($product) => $this->applyCustomerDiscount($product, $customer, $departmentId), $products);
}
public function applyCustomerDiscount(array $product, users_o $customer): array
public function applyCustomerDiscount(array $product, users_o $customer, ?int $departmentId = null): array
{
global $db;
// Validate input
if (!isset($product['id']) || !isset($product['price'])) {
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
}
// Get the customer's discount percentage
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
// Apply the discount to the product price
if ($discount_percentage > 0) {
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price'], true, $departmentId);
}
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}
public static function stripDepartmentPriceSources(array $products): array
{
return array_map(static function (array $product): array {
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}, $products);
}
public static function priceResolutionIsCustomMissing(array $resolution): bool
{
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
}
public function getSubscriptionMonthlyPrice(): int
{
// Subscription price (for 2 washes per month) is 1.2 times the normal price
@@ -4,6 +4,8 @@ namespace objects;
use classes\db;
use classes\object_property;
use classes\price_overrides_schema_bootstrap;
use classes\system_search_cache;
use traits\db_object_t;
class user_price_overrides_o extends db
@@ -14,10 +16,12 @@ class user_price_overrides_o extends db
public object_property $is_category;
public object_property $product_or_category_id;
public object_property $percentage;
public object_property $fixed_price;
public function structure(): void
{
$this->setTable('price_overrides');
price_overrides_schema_bootstrap::ensureColumns();
}
public function objectChanged(): void
@@ -30,6 +34,7 @@ class user_price_overrides_o extends db
$this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true);
$this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true);
$this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true);
$this->fixed_price = new object_property($this->table, $this->id, 'fixed_price', 'int', false, null);
}
public function setUser($user_id): user_price_overrides_o
@@ -43,39 +48,41 @@ class user_price_overrides_o extends db
* @param bool $is_category
* @param int|string $product_or_category_id
* @param int $percentage
* @param int|null $fixed_price
* @return $this
*/
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage): user_price_overrides_o
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage, ?int $fixed_price = null): user_price_overrides_o
{
global $db;
// If the user is not set, return the object
if (!isset($this->user_id)) {
return $this;
}
if ($is_category) {
$fixed_price = null;
}
// Check if the record already exists
$this->removePriceIfExist($is_category, $product_or_category_id);
// If the percentage is 0, return the object
if ($percentage === 0) {
// If neither a discount nor a fixed product price is set, remove the record.
if ($percentage === 0 && $fixed_price === null) {
return $this;
}
// Create a new record in the database
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage) VALUES ($this->user_id, " . (int)$is_category . ", '$product_or_category_id', $percentage)";
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$fixed_price_sql = $fixed_price === null ? 'NULL' : (string)max(0, (int)$fixed_price);
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage, fixed_price) VALUES (" . (int)$this->user_id . ", " . (int)$is_category . ", '$product_or_category_id', " . (int)$percentage . ", $fixed_price_sql)";
$db->query($sql);
$this->markSearchDirty();
return $this;
}
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
{
global $db;
// Get the price override from the database
$sql = "SELECT * FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$result = $db->query($sql);
if ($result->num_rows > 0) {
// Remove the record from the database
$sql = "DELETE FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$db->query($sql);
}
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$sql = "DELETE FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$db->query($sql);
$this->markSearchDirty();
}
/**
@@ -132,6 +139,49 @@ class user_price_overrides_o extends db
return $percentage;
}
public function getFixedPrice(bool $is_category, int|string $product_or_category_id): ?int
{
if ($is_category || !isset($this->user_id)) {
return null;
}
$row = $this->getDirectPriceRow(false, (int)$product_or_category_id);
if ($row === null || $row['fixed_price'] === null) {
return null;
}
return (int)$row['fixed_price'];
}
public function getDirectPriceRow(bool $is_category, int|string $product_or_category_id): ?array
{
global $db;
if (!isset($this->user_id)) {
return null;
}
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$sql = "SELECT * FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id' LIMIT 1";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
$row['id'] = (int)$row['id'];
$row['user_id'] = (int)$row['user_id'];
$row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = $is_category
? (string)$row['product_or_category_id']
: (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage'];
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null
? (int)$row['fixed_price']
: null;
return $row;
}
/**
* Get all the price overrides for the user
* @return array
@@ -153,6 +203,7 @@ class user_price_overrides_o extends db
$row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage'];
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null;
$row['created_at'] = (string)$row['created_at'];
$row['updated_at'] = (string)$row['updated_at'];
// Add the row to the list
@@ -167,10 +218,19 @@ class user_price_overrides_o extends db
'is_category' => true,
'product_or_category_id' => "global",
'percentage' => (int)$economic_user_global_discount,
'fixed_price' => null,
'created_at' => "2021-01-01 00:00:00",
'updated_at' => "2021-01-01 00:00:00"
];
}
return $prices;
}
}
private function markSearchDirty(): void
{
try {
system_search_cache::markDirtyTable($this->table);
} catch (\Throwable) {
}
}
}
+138 -4
View File
@@ -936,9 +936,18 @@ class users_o extends db
* @param bool $is_category If the object is a category
* @return int|null The discount percentage
*/
public function getCustomPrice(int $object_id, bool $is_category = false): int|null
public function getCustomPrice(int $object_id, bool $is_category = false, ?int $department_id = null): int|null
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
$row = (new department_customer_price_overrides_o())->getDirectPriceRow(
(int)$department_id,
(int)$this->id,
$is_category,
$object_id
);
return $row === null ? 0 : (int)$row['percentage'];
}
// Get the custom price for the product
$discount = $this->price_overrides->setUser($this->id)->getPrice($is_category, $object_id);
// If the is_category is false, check if there is a custom price for the category that the product belongs to
@@ -962,9 +971,12 @@ class users_o extends db
* - Category discount (If the product allows category inheritance of discounts)
* - Product discount (If the product has a custom price)
*/
public function getProductDiscountPercentage(int $product_id): int|null
public function getProductDiscountPercentage(int $product_id, ?int $department_id = null): int|null
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
return $this->getDepartmentScopedProductDiscountPercentage($product_id, (int)$department_id);
}
// Get the product by ID
$product = (new products_o())->select((int)$product_id);
$doesProductAllowCategoryDiscount = (bool)$product->apply_category_discount->value();
@@ -981,6 +993,71 @@ class users_o extends db
return $discount_percentage === null ? 0 : (int)$discount_percentage;
}
public function getProductFixedPrice(int $product_id, ?int $department_id = null): ?int
{
self::requireSelected();
if ($department_id !== null && (new departments_o())->isCustomPricingOnly((int)$department_id)) {
$row = (new department_customer_price_overrides_o())->getDirectPriceRow(
(int)$department_id,
(int)$this->id,
false,
(int)$product_id
);
if ($row === null || $row['fixed_price'] === null) {
return null;
}
return (int)$row['fixed_price'];
}
return $this->price_overrides->setUser($this->id)->getFixedPrice(false, $product_id);
}
public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true, ?int $department_id = null): int
{
self::requireSelected();
$fixed_price = $this->getProductFixedPrice($product_id, $department_id);
if ($fixed_price !== null) {
return $fixed_price;
}
$discount_percentage = $use_final_price_discount_calculation
? (int)$this->getProductDiscountPercentage($product_id, $department_id)
: (int)$this->getCustomPrice($product_id, false, $department_id);
if ($discount_percentage <= 0) {
return $base_price;
}
return (int)round($base_price * (1 - ($discount_percentage / 100)));
}
private function getDepartmentScopedProductDiscountPercentage(int $product_id, int $department_id): int
{
self::requireSelected();
$overrides = new department_customer_price_overrides_o();
$product = (new products_o())->select((int)$product_id);
$discounts = [];
$productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id);
if ($productRow !== null) {
$discounts[] = (int)$productRow['percentage'];
}
if ((bool)$product->apply_category_discount->value()) {
$categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value());
if ($categoryRow !== null) {
$discounts[] = (int)$categoryRow['percentage'];
}
$globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global');
if ($globalRow !== null) {
$discounts[] = (int)$globalRow['percentage'];
}
}
return max([0, ...$discounts]);
}
/**
@@ -1077,9 +1154,65 @@ class users_o extends db
return $tmp;
}
/**
* @param array<int, array<string, mixed>> $users
* @return array<int, array<string, mixed>>
*/
public function markLimitedBackofficeManagedUsers(array $users): array
{
$userIds = [];
foreach ($users as $user) {
$userId = (int)($user['id'] ?? 0);
if ($userId > 0) {
$userIds[$userId] = true;
}
}
if ($userIds === []) {
return $users;
}
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` IN (' .
implode(',', array_map('intval', array_keys($userIds))) .
')'
));
$managedUserIds = [];
foreach ($rows as $row) {
$managedUserIds[(int)$row['user_id']] = true;
}
foreach ($users as $key => $user) {
$users[$key]['limited_backoffice_managed'] = isset($managedUserIds[(int)($user['id'] ?? 0)]);
}
return $users;
}
public function isLimitedBackofficeManagedUser(int $userId): bool
{
if ($userId <= 0) {
return false;
}
global $db;
$result = $db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$userId . ' LIMIT 1'
);
return $result !== false && $result->num_rows > 0;
}
public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
{
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) {
if ((bool)($value['limited_backoffice_managed'] ?? false) || (int)($value['customer_number'] ?? -1) === 0) {
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $value['display_name'] ?? null;
continue;
}
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
}
return $listObjectsWithPaginationIfSet;
@@ -1183,15 +1316,16 @@ class users_o extends db
* @param int $object_id The ID of the object
* @param int $discount_percentage The discount percentage
* @param bool $is_category If the object is a category
* @param int|null $fixed_price The fixed product price, when set
* @return void
*/
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false): void
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false, ?int $fixed_price = null): void
{
$this->id = $user_id;
// Get the user object properties
$this->getObjectProperties();
// Set the custom price (key = 'custom_price')
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage);
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage, $fixed_price);
}
public function syncAllUsersEconomicCustomerDetails(): void
@@ -82,7 +82,7 @@ class xlvask_usage_logs_o extends db
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false);
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
}
@@ -195,18 +195,20 @@ class xlvask_usage_logs_o extends db
/**
* Import the usage logs from XL Vask
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
* @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'.
* @param string|null $dateTo Optional inclusive import end date.
* @throws Exception If the objects were not successfully added.
* @returns void
*/
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
{
if (!empty($this->id)) {
throw new Exception('To prevent issues, having a selected object is not allowed.');
}
$usage_logs = $this->getUsageLogsFromXLVask(
date('Y-m-d\TH:i:s.000', strtotime($dateTimeModifier)) // Example: '2025-05-01T00:00:00.000'
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
);
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
$known_usage_logIds = array_map(function ($log) {
return $log['WashId'];
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
unset($new_usage_logs);
}
private static function formatImportDateFrom(?string $dateFrom): string
{
$dateFrom = trim((string)($dateFrom ?? ''));
$timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom);
if ($timestamp === false) {
throw new Exception('Invalid XL Vask usage import dateFrom');
}
return date('Y-m-d\TH:i:s.000', $timestamp);
}
/**
* @param xlvask_usage_log[] $usageLogs
* @return xlvask_usage_log[]
* @throws Exception
*/
private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array
{
$dateTo = trim((string)($dateTo ?? ''));
if ($dateTo === '') {
return $usageLogs;
}
$dateToTimestamp = strtotime($dateTo);
if ($dateToTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
$inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp));
if ($inclusiveEndTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) {
$startTimestamp = strtotime((string)$log->StartTime);
return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp;
}));
}
/**
* This function retrieves the usage logs from XL Vask
* @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
File diff suppressed because it is too large Load Diff
@@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute
$product_cache[$product_id] = (new products_o())->select($product_id);
}
if (!isset($department_price_cache[$department_id][$product_id])) {
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
}
if (!array_key_exists($product_id, $discount_cache)) {
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false, $department_id);
}
$discount_cache[$product_id] = $unit_price;
}
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
$post_discount = (int)$discount_cache[$product_id] * $quantity;
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
}
+17 -8
View File
@@ -291,12 +291,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
// Check if the required fields are set
@@ -340,7 +339,7 @@ class bookingsRoute
);
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -348,12 +347,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
self::requireParameters(['id']);
@@ -401,7 +399,7 @@ class bookingsRoute
}
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -530,4 +528,15 @@ class bookingsRoute
]
);
}
private function requireOwnWashCertificateDownloadAccess(): void
{
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false && $user->exists() && $this->hasPermission('user')) {
return;
}
$this->requirePermission('download_own_wash_certificate');
}
}
+141 -26
View File
@@ -3,7 +3,9 @@
namespace routes;
use classes\authentication;
use classes\cron_scheduler;
use objects\logs_o;
use Throwable;
use traits\route_t;
class cronRoute
@@ -12,39 +14,152 @@ class cronRoute
public function run(): void
{
$this->post('/superuser/cron', function () {
// Get the post data
$this->get('/superuser/cron', function () {
global $response;
// Make sure the user has the SUPERUSER_RUN_CRON permission
$this->requirePermission('SUPERUSER_RUN_CRON');
// Get the user object
$user = (new authentication())->get_user();
// Get the post data
$data = json_decode(file_get_contents('php://input'), true);
// Check if a specific cron job is requested
if (isset($data['job'])) {
// Check if the cron job exists
if (!file_exists(WD . '/cron/' . $data['job'] . '.php')) {
$response->error('Cron job not found', 404);
}
// Include the cron job
require_once WD . '/cron/' . $data['job'] . '.php';
// Log the incident
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_JOB_RUN', 'Ran cron job: ' . $data['job']);
$response->success('Cron job ran successfully');
}
// If no specific cron job is requested, run all cron jobs (through the cron.php file)
require_once WD . '/cron/Cron.php';
// Log the incident
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_RUN', 'Ran all cron jobs');
$this->requireClassicSuperuserPermission('superuser_cron_view');
$response->success((new cron_scheduler())->listTasks());
}, [
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
]);
$this->get('/superuser/cron/runs', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
$task_id = $this->getParameter('task_id');
$limit = (int)($this->getParameter('limit') ?? 50);
$response->success([
'message' => 'All cron jobs ran successfully',
'data' => $response_cron ?? []
'runs' => (new cron_scheduler())->listRuns(is_string($task_id) ? $task_id : null, $limit),
]);
}, [
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
]);
$this->post('/superuser/cron/run', function () {
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
$parameters = $this->getParametersAsArray();
$task_id = trim((string)($parameters['task_id'] ?? ''));
if ($task_id === '') {
$response->error('Missing cron task id.', 400);
}
try {
$run = (new cron_scheduler())->runTask(
$task_id,
'manual',
$this->actorUserId(),
$this->toBool($parameters['force'] ?? false, false)
);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron task: ' . $task_id);
$response->success($run);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'SUPERUSER_RUN_CRON' => 'Run cron jobs',
]);
$this->patch('/superuser/cron/config', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
$parameters = $this->getParametersAsArray();
$task_id = trim((string)($parameters['task_id'] ?? ''));
if ($task_id === '') {
$response->error('Missing cron task id.', 400);
}
try {
$config = [];
if (array_key_exists('enabled', $parameters)) {
$config['enabled'] = $this->toBool($parameters['enabled'], true);
}
if (array_key_exists('schedule', $parameters)) {
$config['schedule'] = $parameters['schedule'];
}
$result = (new cron_scheduler())->updateTaskConfig($task_id, $config);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_TASK_CONFIG_UPDATED', 'Updated cron task: ' . $task_id);
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_cron_manage' => 'Configure cron task schedules and enabled state',
]);
$this->post('/superuser/cron', function () {
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
$parameters = $this->getParametersAsArray();
$scheduler = new cron_scheduler();
try {
if (isset($parameters['job']) && trim((string)$parameters['job']) !== '') {
$job = trim((string)$parameters['job']);
$run = $scheduler->runTask($job, 'manual', $this->actorUserId(), true);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron job: ' . $job);
$response->success([
'message' => 'Cron job ran successfully',
'run' => $run,
]);
}
$result = $scheduler->runDue('manual');
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_RUN', 'Ran due cron jobs');
$response->success([
'message' => 'Due cron jobs ran successfully',
'data' => $result,
]);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
},
[
'SUPERUSER_RUN_CRON' => 'Run cron jobs'
]
);
}
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage cron tasks.', 403);
}
return $this->requirePermission($permission);
}
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
}
private function toBool(mixed $value, bool $default): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === null) {
return $default;
}
$normalized = strtolower(trim((string)$value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $default;
}
}
@@ -16,11 +16,12 @@ class customerAttributes
$this->get('/customer/attributes', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('list_customer_attributes');
// Get the user object
$user = (new authentication())->get_user();
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
// Check if the request was successful
if ($user) {
if ($user || $subuser) {
// Get the query parameters from the URL
$data = $_GET;
// Check if the required fields are set
@@ -32,14 +33,19 @@ class customerAttributes
$response->error('Customer Number must be a number', 400);
}
// Check if the user exists
if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) {
$target_user = (new users_o())->automaticGetTargetUserFromRequest();
if (!$target_user->exists()) {
$response->error('Customer not found', 400);
}
if (!$this->canListTargetCustomerAttributes($target_user)) {
$this->requirePermission('list_customer_attributes');
}
// Log the incident
(new logs_o())->add('customer_attributes', 'global', 1, $user->id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
$actor_id = $user !== false ? (int)$user->id : (int)($subuser->id ?? 0);
(new logs_o())->add('customer_attributes', 'global', 1, $actor_id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
// Return the list of customer notes
$response->success(
(new users_o())->automaticGetTargetUserFromRequest()->getUserAttributes()
$target_user->getUserAttributes()
);
} else {
// Log the incident
@@ -49,7 +55,7 @@ class customerAttributes
}
},
[
'list_customer_attributes' => 'List all customer attributes'
'list_customer_attributes' => 'List all customer attributes. Authenticated customer accounts may list their own customer attributes without this permission.'
]
);
@@ -127,4 +133,29 @@ class customerAttributes
]
);
}
}
private function canListTargetCustomerAttributes(users_o $target_user): bool
{
if (!$target_user->exists()) {
return false;
}
$target_customer_number = (int)$target_user->customer_number->value();
if ($target_customer_number <= 0) {
return false;
}
$auth = new authentication();
$user = $auth->get_user();
if (
$user !== false
&& $user->exists()
&& $this->hasPermission('user')
&& (int)$user->customer_number->value() === $target_customer_number
) {
return true;
}
return $auth->get_subuser() !== false && $this->isOwnCustomerContext($target_customer_number);
}
}
@@ -13,6 +13,7 @@ use DateTimeZone;
use Exception;
use objects\department_daily_report_complaints_o;
use objects\department_daily_reports_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
use objects\users_o;
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
{
use route_t;
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
public function run(): void
{
$this->get('/departments/daily-reports', function () {
@@ -812,6 +815,54 @@ class departmentDailyReportsRoute
]
);
$this->get('/superuser/departments/{id}/overview', function () {
global $response;
$this->requirePermission('superuser_fetch_department');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$department_id_param = (string)($this->fromRoute('id') ?? '');
if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) {
$response->error('Parameter id must be a positive integer', 400);
return;
}
self::requireParameters([
'date',
]);
self::validateDateLocally();
$date_to = $this->getDate_to();
$department_id = (int)$department_id_param;
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview');
$response->success([
'department' => $department->asArray(['slack_webhook' => false]),
'overview' => $this->buildDailyReportOverview(
[$department_id],
(string)self::getParameter('date'),
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
),
]);
},
[
'superuser_fetch_department' => 'Get the superuser department overview'
]
);
$this->get('/departments/daily-reports/overview', function () {
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -848,7 +899,8 @@ class departmentDailyReportsRoute
$this->buildDailyReportOverview(
$department_ids,
(string)self::getParameter('date'),
$date_to
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
)
);
},
@@ -859,6 +911,73 @@ class departmentDailyReportsRoute
]
);
$this->put('/departments/daily-reports/product-targets', function () {
global $response;
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
'department_id',
'product_id',
'target_percentage',
]);
$department_id = (int)self::getParameter('department_id');
if ($department_id <= 0) {
$response->error('Parameter department_id must be a positive integer', 400);
return;
}
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
self::requireDepartmentAccess($department_id);
$product_id = (int)self::getParameter('product_id');
if (!$this->isDailyReportProductId($product_id)) {
$response->error('Invalid daily report product_id', 400);
return;
}
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
if (!$parsed_target['valid']) {
$response->error($parsed_target['message'], 400);
return;
}
$target_percentage = $parsed_target['value'];
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$target_key = $this->dailyReportProductTargetVariableKey($product_id);
if ($target_percentage === null) {
$this->clearDailyReportProductTarget($department_variables, $target_key);
} else {
$department_variables->set($target_key, number_format($target_percentage, 1, '.', ''));
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'Successfully updated department daily report product target');
$response->success([
'department_id' => $department_id,
'product_id' => $product_id,
'target_percentage' => $target_percentage,
]);
},
[
self::SET_PRODUCT_TARGET_PERMISSION => 'Set department daily report product target percentages',
'department_access_:department_id' => 'Access the department'
]
);
$this->get('/departments/daily-reports/product-count', function () {
// Require the user to be logged in
global $response;
@@ -1322,7 +1441,7 @@ class departmentDailyReportsRoute
* }
* @throws Exception
*/
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to): array
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to, bool $include_product_targets = false): array
{
$repository = $this->dailyReportRepository();
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
@@ -1342,6 +1461,11 @@ class departmentDailyReportsRoute
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to);
$product_target_lookup = [];
if ($include_product_targets && count($department_ids) === 1) {
$product_target_lookup = $this->getDailyReportProductTargetsForDepartment((int)$department_ids[0], $product_definitions);
}
return $this->assembleDailyReportOverview(
$department_ids,
$date,
@@ -1352,7 +1476,8 @@ class departmentDailyReportsRoute
$product_summary_lookup,
$complaints_metric,
$night_wash_metric,
$overtime_metric
$overtime_metric,
$product_target_lookup
);
}
@@ -1365,6 +1490,7 @@ class departmentDailyReportsRoute
* @param array<string,mixed> $complaints_metric
* @param array<string,mixed> $night_wash_metric
* @param array<string,mixed> $overtime_metric
* @param array<int,float> $product_target_lookup
* @return array{
* department_ids:array<int>,
* date:string,
@@ -1383,7 +1509,8 @@ class departmentDailyReportsRoute
array $product_summary_lookup,
array $complaints_metric,
array $night_wash_metric,
array $overtime_metric
array $overtime_metric,
array $product_target_lookup = []
): array {
$products = [];
foreach ($product_definitions as $definition) {
@@ -1401,6 +1528,12 @@ class departmentDailyReportsRoute
'state' => 'ready',
'value' => (int)($product_summary['quantity'] ?? 0),
'out_of' => (int)($product_summary['out_of'] ?? 0),
'target_percentage' => array_key_exists($product_id, $product_target_lookup)
? (float)$product_target_lookup[$product_id]
: null,
'target_department_id' => array_key_exists($product_id, $product_target_lookup)
? (int)$department_ids[0]
: null,
];
}
@@ -1459,6 +1592,87 @@ class departmentDailyReportsRoute
return array_values($normalized);
}
private function isDailyReportProductId(int $product_id): bool
{
return in_array(
$product_id,
array_map(static fn(array $definition): int => (int)$definition['product_id'], $this->getDailyReportProductDefinitions()),
true
);
}
/**
* @return array{valid:bool,value:?float,message:string}
*/
private function parseDailyReportProductTargetPercentage(mixed $target_percentage): array
{
if ($target_percentage === null) {
return ['valid' => true, 'value' => null, 'message' => ''];
}
if (is_string($target_percentage)) {
$target_percentage = trim($target_percentage);
if ($target_percentage === '') {
return ['valid' => true, 'value' => null, 'message' => ''];
}
}
if (!is_int($target_percentage) && !is_float($target_percentage) && !(is_string($target_percentage) && is_numeric($target_percentage))) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be numeric, null, or empty'];
}
$target_percentage = round((float)$target_percentage, 1);
if ($target_percentage < 0.0 || $target_percentage > 100.0) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be between 0 and 100'];
}
return ['valid' => true, 'value' => $target_percentage, 'message' => ''];
}
/**
* @param array<int,array{product_id:int,slug:string,title:string}> $product_definitions
* @return array<int,float>
* @throws Exception
*/
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
{
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$targets = [];
foreach ($product_definitions as $definition) {
$product_id = (int)$definition['product_id'];
$stored_target = $department_variables->getVariable($this->dailyReportProductTargetVariableKey($product_id));
if ($stored_target === null || $stored_target === '' || !is_numeric($stored_target)) {
continue;
}
$targets[$product_id] = round((float)$stored_target, 1);
}
return $targets;
}
protected function clearDailyReportProductTarget(department_variables_o $department_variables, string $target_key): void
{
$existing_targets = $department_variables->getFieldsWhere([
'department_id' => $department_variables->department_id,
'variable' => $target_key,
], ['id']);
if (!$existing_targets) {
return;
}
department_variables_o::delete_object('department_variables', (int)$existing_targets[0]['id']);
$department_variables->objectChanged();
}
private function dailyReportProductTargetVariableKey(int $product_id): string
{
return 'daily_report_product_target_percentage_' . $product_id;
}
/**
* @return array<int,array{product_id:int,slug:string,title:string}>
*/
+43 -14
View File
@@ -103,6 +103,7 @@ class departmentsRoute
'economic_department_id',
'visible',
'archived',
'custom_pricing_only',
'longitude',
'latitude',
])
@@ -123,6 +124,12 @@ class departmentsRoute
'latitude' => (float)$department['latitude'],
'order_priority' => (int)$department['order_priority'],
];
if (
$user->hasPermission('superuser_fetch_department')
|| $user->hasPermission('edit_department')
) {
$tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
}
// If the user has the permission to view the slack webhook, add it to the response
if ($user->hasPermission('view_slack_webhook')) {
$tmp_department['slack_webhook'] = $department['slack_webhook'];
@@ -220,6 +227,9 @@ class departmentsRoute
if (self::isParametersSet(['archived'])) {
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
}
if (self::isParametersSet(['custom_pricing_only'])) {
$department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only')));
}
$department->objectChanged();
// Log the incident
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
@@ -240,11 +250,17 @@ class departmentsRoute
$this->get('/departments/categories', function () {
// Require the user to be logged in
global $response;
self::requirePermission('list_department_categories');
// Get the user object
$user = (new authentication())->get_user();
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
// Check if the request was successful
if ($user) {
if ($user || $subuser) {
$isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser;
if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) {
$this->emitForbidden(['list_department_categories']);
}
$responsibleUserId = $user ? (int)$user->id : 0;
// Require the department id
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
@@ -253,14 +269,14 @@ class departmentsRoute
// Validate the department categories object
if (!$department->exists()) {
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
// Return an error
$response->error('Department categories not found', 400);
}
// Get the department categories
$department_categories = new department_categories_o();
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
// Return the list of department categories
$response->success(
$department_categories
@@ -285,7 +301,7 @@ class departmentsRoute
}
},
[
'list_department_categories' => 'List all department categories'
'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.'
]
);
@@ -559,11 +575,6 @@ class departmentsRoute
}
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
{
if (!$enabled) {
// Self-serve disabled: do not mutate lane relay states.
return;
}
$selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
@@ -579,12 +590,30 @@ class departmentsRoute
continue;
}
// Self-serve enabled: keep machine stack off.
if (!$enabled) {
// Self-serve disabled: restore normal/manual relay operation.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatusHard(true);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusHard(true);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
$lane->setMachineRelayStatusHard(true);
});
continue;
}
if (!$department_lane->isSelfServeEnabled()) {
continue;
}
// Self-serve enabled: lances must be usable; machine-only relays stay off.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatus(false);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatus(false);
$lane->setMachineCleanerRelayStatusHard(true);
});
try {
$lane->setMachineRelayStatus(false);
@@ -44,11 +44,38 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices',
]);
$this->get('/limited-backoffice/departments/{departmentId}/customer-pricing', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING);
$departmentId = $this->routePositiveInt('departmentId');
$customerUserId = $this->queryCustomerUserId();
return $service->getDepartmentCustomerPricing($user, $departmentId, $customerUserId);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING => 'View limited backoffice department customer pricing',
]);
$this->put('/limited-backoffice/departments/{departmentId}/customer-pricing', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING);
$departmentId = $this->routePositiveInt('departmentId');
$payload = $this->requestPayload();
$customerUserId = $this->payloadCustomerUserId($payload);
return $service->updateDepartmentCustomerPricing($user, $departmentId, $customerUserId, $payload);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING => 'Manage limited backoffice department customer pricing',
]);
$this->get('/limited-backoffice/roles', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service): array {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->rolePresets();
return $service->rolePresets($user);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
@@ -78,6 +105,26 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/migrate', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission('superuser');
return $service->migrateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
});
}, [
'superuser' => 'Migrate existing employees to limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->put('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -134,4 +181,59 @@ class limitedBackofficeRoute
}
return (int)$value;
}
private function queryPositiveInt(string $name): int
{
$value = $this->fromQuery($name);
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
throw new limited_backoffice_exception('Invalid query parameter.', 400);
}
return (int)$value;
}
private function queryCustomerUserId(): int
{
if ($this->fromQuery('user_id') !== null) {
return $this->queryPositiveInt('user_id');
}
return $this->userIdFromCustomerNumber($this->queryPositiveInt('customer_number'));
}
/**
* @param array<string, mixed> $payload
*/
private function payloadPositiveInt(array $payload, string $name): int
{
$value = $payload[$name] ?? null;
if (is_int($value) && $value > 0) {
return $value;
}
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
throw new limited_backoffice_exception('Invalid request parameter.', 400);
}
return (int)$value;
}
/**
* @param array<string, mixed> $payload
*/
private function payloadCustomerUserId(array $payload): int
{
if (array_key_exists('user_id', $payload)) {
return $this->payloadPositiveInt($payload, 'user_id');
}
return $this->userIdFromCustomerNumber($this->payloadPositiveInt($payload, 'customer_number'));
}
private function userIdFromCustomerNumber(int $customerNumber): int
{
$customer = (new \objects\users_o())->getUserByCustomerNumber($customerNumber);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return (int)$customer->id;
}
}
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/import-usage', function () {
global $response;
self::requirePermission('modules_xlvask_import_usage');
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
// Create the xlvask_usage_logs_o object
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
// Import usage logs
$xlvask_usage_logs_o->importUsageLogs();
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
// Response
$response->success(
'Usage logs imported',
+103 -17
View File
@@ -40,20 +40,15 @@ class orderBookingRoute
$reference = self::getTargetReference(); // String | Null
$po = self::getTargetPo(); // String | Null
$pickup = self::getTargetPickup(); // Bool | Null
$items = self::getTargetItems(); // Array of order_items_o objects
/**
* Permissions (clean helper)
*/
$permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
$permission_other = self::definePermission('add_bookings');
self::allowOwnOrDepartmentAccess(
$permission_own,
$permission_other,
$this->requireOrderBookingCreateAccess(
(int)$customer_number->customer_number->value(),
(int)$department->id,
null,
'You do not have permission to create this order booking.'
(int)$department->id
);
$items = self::getTargetItems(
true,
$customer_number,
(int)$department->id
); // Array of order_items_o objects
/**
* Input data
*/
@@ -96,8 +91,8 @@ class orderBookingRoute
$response->success($order_bookings_o->asArray());
},
[
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
'add_bookings' => 'Permission to create department order bookings.'
'add_bookings' => 'Permission to create order bookings for another customer or department scope.',
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'
]
);
@@ -266,7 +261,6 @@ class orderBookingRoute
$reference = self::getTargetReference(false); // String | Null
$po = self::getTargetPo(false); // String | Null
$pickup = self::getTargetPickup(false); // Bool | Null
$items = self::getTargetItems(false); // Array of order_items_o objects
$order_id_was_set = self::isParametersSet(['order_id']);
$order_id = self::getTargetOrderId(false); // Int | Null
/** Authentication */
@@ -293,6 +287,11 @@ class orderBookingRoute
$ownGuard,
'You do not have permission to edit this order booking.'
);
$items = self::getTargetItems(
false,
$customer_number ?: (new users_o())->getUserByCustomerNumber((int)$object->customer_number->value()),
$department !== null ? (int)$department->id : (int)$object->department->value()
); // Array of order_items_o objects
/**
* Update the object
*/
@@ -659,6 +658,48 @@ class orderBookingRoute
return $object;
}
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
{
$auth = new authentication();
if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) {
$permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
if (!self::hasPermission($permissionOwn, $targetCustomerNumber)) {
$this->emitForbidden([$permissionOwn]);
}
return;
}
if (
$auth->get_user() !== false
&& self::hasPermission('user')
&& $this->isOwnCustomerContext($targetCustomerNumber)
) {
return;
}
$permissionOther = self::definePermission('add_bookings');
if (!self::hasPermission($permissionOther)) {
$this->emitForbidden([$permissionOther]);
}
self::requireDepartmentAccess((string)$departmentId);
}
private function isOrderBookingCustomerSession(): bool
{
try {
$auth = new authentication();
if ($auth->get_subuser() !== false) {
return true;
}
return $auth->get_user() !== false && self::hasPermission('user');
} catch (Exception) {
return false;
}
}
/**
* @throws Exception If the Department is invalid.
*/
@@ -763,7 +804,11 @@ class orderBookingRoute
/**
* @throws Exception
*/
private function getTargetItems(bool $required = true): array|null {
private function getTargetItems(
bool $required = true,
?users_o $customer = null,
?int $department_id = null
): array|null {
global $response;
$parameter = 'items';
$error = 'Invalid items';
@@ -778,11 +823,52 @@ class orderBookingRoute
foreach ($items as $key => $item) {
self::requireType($item, self::type_array());
self::requireValidItem((array)$item, $key);
$items[$key]['name'] = (new products_o())->select((int)$item['id'])->name->value();
$items[$key] = $this->normalizeBookingItem((array)$item, $customer, $department_id);
}
return $items;
}
/**
* @throws Exception
*/
private function normalizeBookingItem(array $item, ?users_o $customer, ?int $department_id): array
{
global $response;
$product = (new products_o())->select((int)$item['id']);
if (!$product->exists()) {
$response->error('Invalid item id', 400);
}
$price = (int)$product->price->value();
if ($department_id !== null) {
$priceResolution = $product->getDepartmentPriceResolution($department_id);
$price = (int)$priceResolution['price'];
if (
$customer !== null
&& $customer->exists()
&& !products_o::priceResolutionIsCustomMissing($priceResolution)
) {
$price = $customer->applyProductCustomerPricing(
(int)$product->id,
$price,
true,
$department_id
);
}
} elseif ($customer !== null && $customer->exists()) {
$price = $customer->applyProductCustomerPricing((int)$product->id, $price);
}
return [
...$item,
'id' => (int)$product->id,
'name' => (string)$product->name->value(),
'quantity' => (int)$item['quantity'],
'price' => $price,
];
}
private function cleanReg(string $reg): string
{
// Remove all non-alphanumeric characters
+151 -12
View File
@@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary;
use classes\economic_v2_compare_engine;
use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service;
use classes\invoice_collection_bulk_action_service;
use classes\invoicing_period_utils;
use classes\response;
use classes\router;
@@ -612,21 +613,46 @@ class orderInvoicesRoute
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
);
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
$invoice_collection_ids = [];
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
if (self::isParametersSet(['invoice_collection_ids'])) {
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids_raw)) {
$response->error('invoice_collection_ids must be an array', 400);
}
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
$invoice_collection_id = (int)$invoice_collection_id_raw;
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
$invoice_collection_ids[] = $invoice_collection_id;
}
$invoice_collection_ids = array_values(array_unique($invoice_collection_ids));
if (empty($invoice_collection_ids)) {
$response->error('invoice_collection_ids must contain at least one id', 400);
}
} else {
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
$invoice_collection_ids[] = $invoice_collection_id;
}
}
}
$items = [];
@@ -686,6 +712,105 @@ class orderInvoicesRoute
]
);
/** Collected order invoices > Bulk action preview > POST */
$this->post('/collected-invoices/bulk-actions/preview', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'PREVIEW_COLLECTED_INVOICE_BULK_ACTION', 'User tried to preview a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['action', 'invoice_collection_ids']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$preview = (new invoice_collection_bulk_action_service())->preview(
$action,
$invoice_collection_ids,
$options,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user->id,
'PREVIEW_COLLECTED_INVOICE_BULK_ACTION',
'User previewed collected invoice bulk action ' . $action . ' for ' . count($invoice_collection_ids) . ' invoice collections'
);
$response->success($preview);
},
[
'reset_collected_invoice_economic' => 'Preview collected invoice bulk cleanup and price reset actions. This is a superuser-only route.',
'move_collected_invoice' => 'Preview merging selected collected invoices. This is a superuser-only route.',
'split_collected_invoice' => 'Preview splitting selected collected invoices by order month. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Preview queueing selected collected invoices for E-Conomic. This is a superuser-only route.',
]
);
/** Collected order invoices > Bulk action apply > POST */
$this->post('/collected-invoices/bulk-actions/apply', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'APPLY_COLLECTED_INVOICE_BULK_ACTION', 'User tried to apply a collected invoice bulk action without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']);
$action = (string)self::getParameter('action');
$this->requireCollectedInvoiceBulkActionPermission($action);
$invoice_collection_ids = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids)) {
$response->error('invoice_collection_ids must be an array', 400);
}
$options = self::isParametersSet(['options']) ? self::getParameter('options') : [];
if (!is_array($options)) {
$response->error('options must be an object', 400);
}
$locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da';
try {
$result = (new invoice_collection_bulk_action_service())->apply(
(string)self::getParameter('preview_id'),
$action,
$invoice_collection_ids,
$options,
(string)self::getParameter('confirmation_text'),
(int)$user->id,
$locale
);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success($result);
},
[
'reset_collected_invoice_economic' => 'Apply collected invoice bulk cleanup and price reset actions after confirmation. This is a superuser-only route.',
'move_collected_invoice' => 'Apply merging selected collected invoices after confirmation. This is a superuser-only route.',
'split_collected_invoice' => 'Apply splitting selected collected invoices by order month after confirmation. This is a superuser-only route.',
'add_collected_invoice_economic' => 'Apply queueing selected collected invoices for E-Conomic after confirmation. This is a superuser-only route.',
]
);
/** Collected order invoices > E-Conomic > POST (queued) */
$this->post('/collected-invoices/economic', function () {
global $response;
@@ -2496,6 +2621,20 @@ class orderInvoicesRoute
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
}
private function requireCollectedInvoiceBulkActionPermission(string $action): void
{
$permission = match ($action) {
invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES,
invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES => 'reset_collected_invoice_economic',
invoice_collection_bulk_action_service::ACTION_MERGE => 'move_collected_invoice',
invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH => 'split_collected_invoice',
invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC => 'add_collected_invoice_economic',
default => throw new Exception('Invalid invoice collection bulk action.'),
};
self::requirePermission($permission);
}
/**
* @param $collected_order_invoice
* @param users_o $users
+70 -10
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\customer_product_rule_service;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
@@ -70,10 +71,29 @@ class orderItemsRoute
$price = (int)self::getParameter('price');
}
}
$order = (new orders_o())->getOrderById((int)$data['order_id']);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
}
$customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) {
(new logs_o())->add(
'order_items',
'global',
1,
$user->id,
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
);
$response->error($customerRuleViolation['message'], 400);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
@@ -155,18 +175,38 @@ class orderItemsRoute
$this->delete('/order/items', function () {
// Require the user to be logged in
global $response;
global $response, $db;
$this->requirePermission('delete_order_items');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Get the query data
$data = $_GET;
// Check if the required fields are set
if (!isset($data['id'])) {
// Get the order item id from the query string or request body
$itemIdRaw = $this->fromRequest('id');
if ($itemIdRaw === null || $itemIdRaw === '') {
$response->error('Order Item ID is required', 400);
}
$data = ['id' => $itemIdRaw];
// Look up the order item to check department access
$itemId = (int)$data['id'];
$stmt = $db->prepare('SELECT oi.order_id FROM order_items oi WHERE oi.id = ? LIMIT 1');
if ($stmt === false) {
(new logs_o())->add('order_items', 'global', 1, 0, 'DELETE_ORDER_ITEMS', 'Database error while preparing department access check query');
$response->error('Database error while checking department access', 500);
}
$stmt->bind_param('i', $itemId);
$stmt->execute();
$orderItemRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($orderItemRow !== null) {
$orderForAccess = (new orders_o())->getOrderById((int)$orderItemRow['order_id']);
if (!$orderForAccess->exists()) {
$response->error('Order not found', 404);
}
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
} else {
$response->error('Order item not found', 404);
}
// Delete the order item
(new order_items_o())->removeOrderItem((int)$data['id']);
// Return the list of departments
@@ -187,7 +227,7 @@ class orderItemsRoute
$this->put('/order/items', function () {
// Require the user to be logged in
global $response;
global $response, $db;
$this->requirePermission('edit_order_items');
// Get the user object
$user = (new authentication())->get_user();
@@ -212,19 +252,39 @@ class orderItemsRoute
$response->error('Quantity is required', 400);
}
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
$orderItemId = (int)$data['id'];
$orderItem = (new order_items_o())->getOrderItemById($orderItemId);
if (!$orderItem->exists()) {
$response->error('Order item not found', 404);
}
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
$orderItemContextResult = $db->query(
"SELECT oi.order_id, oi.product_id, p.name AS product_name, p.requires_note AS product_requires_note
FROM order_items oi
LEFT JOIN products p ON p.id = oi.product_id
WHERE oi.id = {$orderItemId}
LIMIT 1"
);
$orderItemContext = $orderItemContextResult ? $orderItemContextResult->fetch_assoc() : null;
if ($orderItemContext === null) {
$response->error('Order item not found', 404);
}
if ($orderItemContext['product_id'] === null || $orderItemContext['product_name'] === null) {
$response->error('Product not found', 404);
}
if (products_o::productDataRequiresOrderItemNote([
'id' => (int)$orderItemContext['product_id'],
'name' => (string)$orderItemContext['product_name'],
'requires_note' => (bool)$orderItemContext['product_requires_note'],
]) && trim((string)$data['notes']) === '') {
$response->error('Notes is required for this product', 400);
}
$order = (new orders_o())->getOrderById((int)$orderItem->order_id->value());
$order = (new orders_o())->getOrderById((int)$orderItemContext['order_id']);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$data['department_id']);
// Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Get the base64 file
$base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Delete the attachment
$order->removeAttachment((int)$attachment_id);
// Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value());
// Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
}
// Admin/department path (requires edit_order)
self::requirePermission($permission_other);
/** Departmental access */
/** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the target department
self::requireDepartmentAccess((string)(int)$data['department_id']);
$order->department_id->set((int)$data['department_id']);
}
// If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{
try {
$user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
} catch (\Throwable) {
return false;
}
+196 -79
View File
@@ -20,37 +20,121 @@ class productsRoute
* Get the customer object if the customer_id parameter is provided (In the request 'customer_id')
* @return users_o|null
*/
private function getCustomerIfProvided(): ?users_o
private function getCustomerIfProvided(bool $restrictToOwnCustomer = false): ?users_o
{
global $response;
if (self::isParametersSet(['customer_id'])) {
$customerId = (int)self::getParameter('customer_id');
try {
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
$customerId = $this->getCustomerIdIfProvided();
if ($customerId === null) {
return null;
}
if ($restrictToOwnCustomer && !$this->isOwnCustomerContext($customerId)) {
$this->emitForbidden(['list_products']);
}
try {
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
return null;
}
private function getCustomerIdIfProvided(): ?int
{
return $this->getOptionalPositiveIntParameter('customer_id');
}
/**
* Get the department id if the department_id parameter is provided (In the request 'department_id')
* @return int|null
*/
private function getDepartmentIdIfProvided(): ?int
{
return $this->getOptionalPositiveIntParameter('department_id');
}
private function getOptionalPositiveIntParameter(string $parameter): ?int
{
global $response;
if (self::isParametersSet(['department_id'])) {
return (int)self::getParameter('department_id');
if (!self::isParametersSet([$parameter])) {
return null;
}
return null;
$value = self::getParameter($parameter);
if ($this->isNullLikeOptionalParameter($value)) {
return null;
}
$parsed = null;
if (is_int($value)) {
$parsed = $value;
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$parsed = (int)trim($value);
} else {
$response->error('Invalid ' . $parameter, 400);
}
if ($parsed === null || $parsed <= 0) {
$response->error('Invalid ' . $parameter, 400);
}
return $parsed;
}
private function isNullLikeOptionalParameter(mixed $value): bool
{
if ($value === null) {
return true;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
}
private function isCustomerBookingSession(bool $hasAuthenticatedUser, bool $hasCustomerPermission, bool $isSubuserSession): bool
{
return ($hasAuthenticatedUser && $hasCustomerPermission) || $isSubuserSession;
}
private function canReadProductList(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool
{
return $isCustomerBookingSession || $hasListProductsPermission;
}
private function shouldRestrictCustomerBookingProducts(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool
{
return $isCustomerBookingSession && !$hasListProductsPermission;
}
private function canUseCustomerBookingDepartmentPricing(bool $isCustomerBookingSession, bool $useFinalPrice, ?int $customerId): bool
{
if (!$isCustomerBookingSession || !$useFinalPrice) {
return false;
}
return $customerId === null || $this->isOwnCustomerContext($customerId);
}
private function isBookingVisibleProduct(array $product): bool
{
return (bool)($product['display_in_booking_form'] ?? false);
}
private function filterProductsVisibleOnBookingForm(array $products): array
{
return array_values(array_filter($products, function ($product): bool {
return is_array($product) && $this->isBookingVisibleProduct($product);
}));
}
/**
@@ -59,11 +143,7 @@ class productsRoute
*/
private function getCategoryIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['category'])) {
return (int)self::getParameter('category');
}
return null;
return $this->getOptionalPositiveIntParameter('category');
}
/**
@@ -72,11 +152,7 @@ class productsRoute
*/
private function getProductIdIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['id'])) {
return (int)self::getParameter('id');
}
return null;
return $this->getOptionalPositiveIntParameter('id');
}
/**
@@ -91,12 +167,14 @@ class productsRoute
// Check if the departmentId is set
if ($departmentId) {
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
}
// Check if the customer is set
if ($customer !== null) {
// Apply the customers unique discounts
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
$products = (new products_o())->applyCustomerDiscounts($products, $customer, $departmentId);
} else {
$products = products_o::stripDepartmentPriceSources($products);
}
return $products;
}
@@ -132,30 +210,44 @@ class productsRoute
global $response;
$permission_node = 'list_products';
$isProductDetailsRestricted = true;
if ($this->isAuthenticated()) {
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
$hasAuthenticatedUser = $user !== false && $user !== null;
$isSubuserSession = $subuser !== false;
$hasCustomerPermission = $hasAuthenticatedUser ? self::hasPermission('user') : false;
$isCustomerBookingSession = $this->isCustomerBookingSession($hasAuthenticatedUser, $hasCustomerPermission, $isSubuserSession);
$hasListProductsPermission = $hasAuthenticatedUser ? self::hasPermission($permission_node) : false;
if ($hasAuthenticatedUser || $isSubuserSession) {
$isProductDetailsRestricted = false;
$this->requirePermission($permission_node);
if (!$this->canReadProductList($isCustomerBookingSession, $hasListProductsPermission)) {
$this->emitForbidden([$permission_node]);
}
}
// Get the user object
$user = (new authentication())->get_user();
// Set the user id to 0 if guest
$responsibleUserId = $isProductDetailsRestricted ? 0 : $user->id;
function parseProduct($product, $isGuest): array
{
$responsibleUserId = $hasAuthenticatedUser ? (int)$user->id : 0;
$parseProduct = function ($product, $isGuest, $onlyBookingVisible = false): array {
$addons = (new product_options_o())->getProductOptions($product['id']);
if ($onlyBookingVisible) {
$addons = array_values(array_filter($addons, function ($option): bool {
return (bool)($option['product']['display_in_booking_form'] ?? false);
}));
}
$tmpProduct = [
'id' => (int)$product['id'],
'name' => (string)$product['name'],
'description' => (string)$product['description'],
'price' => (int)$product['price'],
'subscription_allowed' => (boolean)$product['subscription_allowed'],
'subscription_allowed' => (bool)$product['subscription_allowed'],
'category' => (int)$product['category'],
'piktogram' => (string)$product['piktogram'],
'economic_product_id' => (int)$product['economic_product_id'],
'apply_category_discount' => (boolean)$product['apply_category_discount'],
'apply_category_discount' => (bool)$product['apply_category_discount'],
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'],
'addons' => (new product_options_o())->getProductOptions($product['id']),
'addons' => $addons,
'is_wash' => (bool)$product['is_wash'],
'display_in_booking_form' => (bool)$product['display_in_booking_form'],
'order_priority' => (int)$product['order_priority'],
@@ -190,17 +282,27 @@ class productsRoute
];
}
return $isGuest ? $tmpProductGuest : $tmpProduct;
}
};
// Check if the request was successful
if ($user || $isProductDetailsRestricted) {
if ($hasAuthenticatedUser || $isSubuserSession || $isProductDetailsRestricted) {
// Define the variables
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$restrictCustomerBookingProducts = $this->shouldRestrictCustomerBookingProducts($isCustomerBookingSession, $hasListProductsPermission);
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
$customerId = $this->getCustomerIdIfProvided();
$customer = $this->getCustomerIfProvided($restrictCustomerBookingProducts); // This is only used if the customer_id parameter is provided
$departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
if (
$departmentId !== null
&& !$restrictCustomerBookingProducts
&& !$this->canUseCustomerBookingDepartmentPricing($isCustomerBookingSession, $useFinalPrice, $customerId)
) {
self::requireDepartmentAccess((string)$departmentId);
}
$category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
// Check if the "final_price" parameter is set, and true.
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
if ($useFinalPrice) {
// Determine the products to return
if ($category) {
// Get products in the category
@@ -223,21 +325,24 @@ class productsRoute
} else {
// Get all products
$products = (array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
function ($product) use ($isProductDetailsRestricted, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted);
}
);
}
if ($restrictCustomerBookingProducts && !$productId) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Return all products, with the department pricing and customer discounts applied
//$response->success(
// array_map(function ($product) {
// return parseProduct($product);
// }, self::parseProductsPrice($products, $customer, $departmentId))
//);
$result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted) {
$productArray = parseProduct($product, $isProductDetailsRestricted);
$result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
$productArray = $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
// Get the price of the product with the department pricing and customer discounts applied
$productArray = parseProduct(self::parseProductsPrice([$productArray], $customer, $departmentId)[0], $isProductDetailsRestricted);
$productArray = $parseProduct(self::parseProductsPrice([$productArray], $customer, $departmentId)[0], $isProductDetailsRestricted, $restrictCustomerBookingProducts);
// Get the options for the product
$productArray['addons'] = self::parseOptionsPrice($productArray['addons'], $customer, $departmentId);
// Return the product with the updated price
@@ -251,60 +356,72 @@ class productsRoute
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed product with id ' . $response->getRequestParameter('id'));
// Return the product
$product = (new products_o())->select((int)self::getParameter('id'))->asArray();
if ($restrictCustomerBookingProducts && !$this->isBookingVisibleProduct($product)) {
$response->success([]);
}
$response->success(
parseProduct(
(new products_o())->select((int)self::getParameter('id'))->asArray(), $isProductDetailsRestricted
$parseProduct(
$product, $isProductDetailsRestricted, $restrictCustomerBookingProducts
)
);
}
// Check if the category is set in the request
$data = $_GET ?? [];
// Check if the category is set
if (isset($data['category'])) {
if ($category !== null) {
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
// Return the list of products
$products = (new products_o())->listObjectsByCategory($data['category']);
$products = (new products_o())->listObjectsByCategory($category);
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
}
$response->success(
array_map(function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
array_map(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
}, $products)
);
}
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
// Get all product ids contained in a category attached to the department
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
// Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) {
return $product->id;
}, $departmentSpecificProducts);
$products = (array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
// Only include products that are in the department specific product ids
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
},
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
);
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm($products);
}
// Return the list of products
$response->success(
(new products_o())->applyDepartmentPricing((array)(new products_o())->listObjectsWithPaginationIfSet(
function ($product) use ($isProductDetailsRestricted, $departmentSpecificProductIds) {
// Only include products that are in the department specific product ids
return parseProduct($product, $isProductDetailsRestricted);
},
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
), (int)$data['department_id'])
(new products_o())->applyDepartmentPricing($products, $departmentId)
);
}
// Return the list of products
$response->success(
(new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted) {
return parseProduct($product, $isProductDetailsRestricted);
})
);
$products = (new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) {
return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts);
});
if ($restrictCustomerBookingProducts) {
$products = $this->filterProductsVisibleOnBookingForm((array)$products);
}
$response->success($products);
} else {
// Log the incident
(new logs_o())->add('products', 'global', 1, 0, 'LIST_PRODUCTS', 'No user found, or invalid session');
@@ -313,7 +430,7 @@ class productsRoute
}
},
[
'list_products' => 'List all products'
'list_products' => 'List all products. Authenticated customer booking sessions may read booking-visible products without the permission.'
]
);
+21 -1
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o;
use objects\logs_o;
use traits\route_t;
@@ -106,6 +107,25 @@ class rolesRoute
]
);
self::get('/roles/limited-backoffice-permission-templates', function () {
global $response;
self::requirePermission('superuser');
self::requirePermission('add_role_permission');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
} else {
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
$response->error('Invalid session', 400);
}
},
[
'superuser' => 'Access the superuser interface',
'add_role_permission' => 'Add a permission to a role'
]
);
self::post('/roles/permissions', function () {
// Require the user to be logged in
global $response;
@@ -182,4 +202,4 @@ class rolesRoute
]
);
}
}
}
+522 -35
View File
@@ -6,6 +6,7 @@ use classes\authentication;
use classes\economic;
use classes\gatewayapi;
use classes\response;
use classes\subuser_permission_templates_service;
use classes\virkdata;
use Exception;
use modules\virkdata\helpers\virkdata_response;
@@ -120,6 +121,27 @@ class subusersRoute
return array_values(array_unique($permissions));
}
/**
* @return array{enabled:bool,permissions:array<int,string>}|null
*/
private function parseAccessTemplatePayload(): ?array
{
global $response;
if (!self::isParametersSet(['permission_template_key'])) {
return null;
}
$templateKey = (string)self::getParameter('permission_template_key');
try {
return (new subuser_permission_templates_service())->expandTemplate($templateKey);
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
return null;
}
private function normalizeOptionalString(mixed $value): ?string
{
if ($value === null) {
@@ -337,6 +359,7 @@ class subusersRoute
{
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
$templateService = new subuser_permission_templates_service();
$setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired;
@@ -370,6 +393,8 @@ class subusersRoute
'grant_note' => $grant ? $grant->note->value() : null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'access_state' => $accessState,
];
}
@@ -429,8 +454,8 @@ class subusersRoute
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'g.`billing_customer_number`',
'grant_id' => 'g.`id`',
'customer_number' => 'customer_number_sort',
'grant_id' => 'grant_id_sort',
'name' => 's.`name`',
];
@@ -462,10 +487,10 @@ class subusersRoute
$statement->bind_param($types, ...$refs);
}
private function buildSuperuserSubuserManagementPayload(array $row): array
private function buildSuperuserGrantPayload(array $row, bool $setupRequired): array
{
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
$templateService = new subuser_permission_templates_service();
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$accessState = 'inactive';
@@ -475,6 +500,44 @@ class subusersRoute
$accessState = 'disabled';
}
return [
'grant_id' => (int)$row['grant_id'],
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'access_state' => $accessState,
];
}
private function buildSuperuserSubuserManagementPayload(array $row, array $grantRows = []): array
{
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
if ($grantRows === [] && !empty($row['grant_id'])) {
$grantRows = [$row];
}
$grants = array_map(
fn (array $grantRow): array => $this->buildSuperuserGrantPayload($grantRow, $setupRequired),
$this->dedupeSuperuserGrantRows($grantRows)
);
$primaryGrant = $this->selectPrimarySuperuserGrant($grants);
$accessState = 'inactive';
if (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'active') !== []) {
$accessState = 'active';
} elseif (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'pending_setup') !== []) {
$accessState = 'pending_setup';
} elseif ($grants !== []) {
$accessState = 'disabled';
}
return [
'id' => (int)$row['id'],
'username' => $row['username'] ?? null,
@@ -490,20 +553,178 @@ class subusersRoute
'invite_accepted' => !$setupRequired,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_id' => (int)$row['grant_id'],
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'customer_number' => $primaryGrant['customer_number'] ?? null,
'customer_name' => $primaryGrant['customer_name'] ?? null,
'grant_id' => $primaryGrant['grant_id'] ?? null,
'grant_enabled' => $primaryGrant['grant_enabled'] ?? false,
'grant_note' => $primaryGrant['grant_note'] ?? null,
'grant_permissions' => $primaryGrant['grant_permissions'] ?? [],
'permissions' => $primaryGrant['permissions'] ?? [],
'permission_template_key' => $primaryGrant['permission_template_key'] ?? subuser_permission_templates_service::TEMPLATE_DEACTIVATED,
'permission_groups' => $primaryGrant['permission_groups'] ?? [],
'grant_created_at' => $primaryGrant['grant_created_at'] ?? null,
'grant_updated_at' => $primaryGrant['grant_updated_at'] ?? null,
'grants' => $grants,
'grant_count' => count($grants),
'customer_numbers' => array_values(array_unique(array_map(
static fn (array $grant): int => (int)$grant['customer_number'],
$grants
))),
'access_state' => $accessState,
];
}
private function listSuperuserSubusers(): array
private function dedupeSuperuserGrantRows(array $grantRows): array
{
$byCustomer = [];
foreach ($grantRows as $grantRow) {
$customerNumber = (int)($grantRow['customer_number'] ?? 0);
if ($customerNumber <= 0) {
continue;
}
$existing = $byCustomer[$customerNumber] ?? null;
if ($existing === null || $this->compareSuperuserGrantRows($grantRow, $existing) < 0) {
$byCustomer[$customerNumber] = $grantRow;
}
}
$deduped = array_values($byCustomer);
usort($deduped, fn (array $left, array $right): int => $this->compareSuperuserGrantRows($left, $right));
return $deduped;
}
private function compareSuperuserGrantRows(array $left, array $right): int
{
$leftEnabled = (int)($left['grant_enabled'] ?? 0);
$rightEnabled = (int)($right['grant_enabled'] ?? 0);
if ($leftEnabled !== $rightEnabled) {
return $rightEnabled <=> $leftEnabled;
}
$leftCustomer = (int)($left['customer_number'] ?? 0);
$rightCustomer = (int)($right['customer_number'] ?? 0);
if ($leftCustomer !== $rightCustomer) {
return $leftCustomer <=> $rightCustomer;
}
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
}
private function selectPrimarySuperuserGrant(array $grants): ?array
{
if ($grants === []) {
return null;
}
$sorted = $grants;
usort($sorted, static function (array $left, array $right): int {
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
$leftCreated = strtotime((string)($left['grant_created_at'] ?? '')) ?: 0;
$rightCreated = strtotime((string)($right['grant_created_at'] ?? '')) ?: 0;
if ($leftCreated !== $rightCreated) {
return $rightCreated <=> $leftCreated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
});
return $sorted[0];
}
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function resolveSuperuserSubuserTargetUser(int $userId): array
{
global $response;
$targetUser = (new users_o())->select($userId);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
$targetUser->getObjectProperties();
$customerNumber = (int)$targetUser->customer_number->value();
if ($customerNumber <= 0) {
$response->error('Selected user does not have a customer number', 400);
}
$customerName = $targetUser->display_name->value();
if (!is_string($customerName) || trim($customerName) === '') {
$customerName = $this->resolveCustomerName($customerNumber);
}
return [
'user_id' => (int)$targetUser->id,
'customer_number' => $customerNumber,
'customer_name' => $customerName,
];
}
private function buildSubuserSummaryForCustomer(int $customerNumber): array
{
global $db;
$statement = $db->conn->prepare("
SELECT
COUNT(*) AS `total`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') <> '' THEN 1 ELSE 0 END) AS `active`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') = '' THEN 1 ELSE 0 END) AS `pending_setup`,
SUM(CASE WHEN g.`enabled` = 0 THEN 1 ELSE 0 END) AS `disabled`
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
WHERE g.`deleted_at` IS NULL
AND g.`billing_customer_number` = ?
");
if ($statement === false) {
throw new Exception('Failed to prepare subuser summary query: ' . $db->conn->error);
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$row = $result->fetch_assoc() ?: [];
$statement->close();
return [
'total' => (int)($row['total'] ?? 0),
'active' => (int)($row['active'] ?? 0),
'pending_setup' => (int)($row['pending_setup'] ?? 0),
'disabled' => (int)($row['disabled'] ?? 0),
];
}
private function addUserScopedSubuserMeta(array $targetUser): void
{
global $response;
$response->add_meta('user_context', $targetUser);
$response->add_meta('subusers_summary', $this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}
private function listSuperuserSubusers(?int $customerNumber = null): array
{
global $db, $response;
@@ -521,6 +742,11 @@ class subusersRoute
if (!$includeNonEnabled) {
$where[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$where[] = 'g.`billing_customer_number` = ?';
$params[] = $customerNumber;
$types .= 'i';
}
if ($pagination['search'] !== null) {
$where[] = "(
@@ -532,7 +758,12 @@ class subusersRoute
OR CAST(s.`phone` AS CHAR) LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` LIKE ?
OR u.`display_name` LIKE ?
OR EXISTS (
SELECT 1
FROM `users` search_u
WHERE search_u.`customer_number` = g.`billing_customer_number`
AND search_u.`display_name` LIKE ?
)
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) {
@@ -545,10 +776,9 @@ class subusersRoute
$fromSql = "
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number`
";
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
$countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
@@ -559,7 +789,7 @@ class subusersRoute
$total = (int)($countResult->fetch_assoc()['count'] ?? 0);
$countStatement->close();
$dataSql = "
$pageSql = "
SELECT
s.`id`,
s.`username`,
@@ -572,31 +802,100 @@ class subusersRoute
s.`created_at`,
s.`updated_at`,
s.`suspended_at`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`,
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
u.`display_name` AS `customer_name`
MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`,
MIN(g.`billing_customer_number`) AS `customer_number_sort`,
MAX(g.`id`) AS `grant_id_sort`
$fromSql
$whereSql
GROUP BY
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ?
";
$dataStatement = $db->conn->prepare($dataSql);
if ($dataStatement === false) {
$pageStatement = $db->conn->prepare($pageSql);
if ($pageStatement === false) {
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
}
$dataParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams);
$dataStatement->execute();
$result = $dataStatement->get_result();
$pageParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($pageStatement, $types . 'ii', $pageParams);
$pageStatement->execute();
$result = $pageStatement->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);
$dataStatement->close();
$pageStatement->close();
$subuserIds = array_map(static fn (array $row): int => (int)$row['id'], $rows);
$grantRowsBySubuserId = [];
if ($subuserIds !== []) {
$placeholders = implode(',', array_fill(0, count($subuserIds), '?'));
$grantWhere = [
'g.`deleted_at` IS NULL',
'g.`subuser` IN (' . $placeholders . ')',
];
$grantParams = $subuserIds;
$grantTypes = str_repeat('i', count($subuserIds));
if (!$includeNonEnabled) {
$grantWhere[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$grantWhere[] = 'g.`billing_customer_number` = ?';
$grantParams[] = $customerNumber;
$grantTypes .= 'i';
}
$grantSql = "
SELECT
g.`subuser` AS `subuser_id`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`
FROM `subuser_grants` g
WHERE " . implode(' AND ', $grantWhere) . "
ORDER BY
g.`subuser` ASC,
g.`enabled` DESC,
g.`billing_customer_number` ASC,
COALESCE(g.`updated_at`, g.`created_at`) DESC,
g.`id` DESC
";
$grantStatement = $db->conn->prepare($grantSql);
if ($grantStatement === false) {
throw new Exception('Failed to prepare subuser grant list query: ' . $db->conn->error);
}
$this->bindStatementParameters($grantStatement, $grantTypes, $grantParams);
$grantStatement->execute();
$grantResult = $grantStatement->get_result();
$grantRows = $grantResult->fetch_all(MYSQLI_ASSOC);
$grantStatement->close();
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grantRow): int => (int)($grantRow['customer_number'] ?? 0),
$grantRows
));
foreach ($grantRows as $grantRow) {
$subuserId = (int)$grantRow['subuser_id'];
$customerNumberForGrant = (int)$grantRow['customer_number'];
$grantRow['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
$grantRowsBySubuserId[$subuserId] ??= [];
$grantRowsBySubuserId[$subuserId][] = $grantRow;
}
}
$response->paginate(
(int)$pagination['page'],
@@ -607,7 +906,107 @@ class subusersRoute
[$pagination['order_field'] => $pagination['order_direction']]
);
return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows);
return array_map(
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, $grantRowsBySubuserId[(int)$row['id']] ?? []),
$rows
);
}
private function getGrantForScopedUserOrFail(int $grantId, int $customerNumber): subuser_grants_o
{
global $response;
$grant = (new subuser_grants_o())->select($grantId);
if (!$grant->exists()) {
$response->error('Subuser grant not found', 404);
}
$grant->getObjectProperties();
if ((int)$grant->billing_customer_number->value() !== $customerNumber || $grant->deleted_at->value() !== null) {
$response->error('Subuser grant not found for selected user', 404);
}
return $grant;
}
private function patchScopedSubuserGrant(int $grantId, int $customerNumber): void
{
global $response;
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
$updates = [];
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$updates['enabled'] = $templateAccess['enabled'];
$updates['permissions'] = $templateAccess['permissions'];
}
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($tmp === null) {
$response->error('Invalid enabled value', 400);
}
$updates['enabled'] = (bool)$tmp;
}
if (self::isParametersSet(['note'])) {
$note = $this->normalizeOptionalString(self::getParameter('note'));
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$updates['note'] = $note;
}
if (self::isParametersSet(['permissions'])) {
$updates['permissions'] = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
}
if ($updates === []) {
$response->error('No fields to update', 400);
}
try {
$grant->update($updates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$updatedGrant = (new subuser_grants_o())->select($grantId);
$updatedGrant->getObjectProperties();
$subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value());
$subuser->getObjectProperties();
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $updatedGrant->asArray(),
]);
}
private function resendInviteForScopedUser(int $subuserId, int $customerNumber): void
{
global $response;
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
$response->error('Subuser not found', 404);
}
$subuser->getObjectProperties();
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
if ($grant === null) {
$response->error('Subuser grant not found for selected user', 404);
}
if (!$subuser->requiresSetup()) {
$response->error('Driver account already accepted the invitation.', 409);
}
$invite = $this->issueSetupInvite($subuser);
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $grant->asArray(),
'invite' => $invite,
]);
}
private function handleInviteSubuserForCustomer(int $customerNumber): void
@@ -617,6 +1016,9 @@ class subusersRoute
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
if (self::isParametersSet(['customer_number']) && (int)self::getParameter('customer_number') !== $customerNumber) {
$response->error('Customer number does not match selected user', 400);
}
self::requireParameters(['name', 'phone_country_code', 'phone']);
@@ -624,6 +1026,7 @@ class subusersRoute
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$templateAccess = $this->parseAccessTemplatePayload();
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -632,6 +1035,10 @@ class subusersRoute
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
@@ -813,9 +1220,14 @@ class subusersRoute
self::requireType($note, self::type_string());
self::requireMaxLength('note', 65535);
}
$templateAccess = $this->parseAccessTemplatePayload();
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
$response->success(['grant' => $grant->asArray()]);
@@ -853,6 +1265,12 @@ class subusersRoute
}
}
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$grant->enabled->set($templateAccess['enabled']);
$grant->permissions->set($templateAccess['permissions']);
}
// Update fields provided in the request
if (self::isParametersSet(['enabled'])) {
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -923,6 +1341,25 @@ class subusersRoute
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
]);
$this->get('/subusers/permission-templates', function () {
global $response;
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|| self::hasPermission('list_subusers')
|| self::hasPermission('add_subusers')
|| self::hasPermission('edit_subusers');
if (!$canUseGlobalManagement) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
}
$response->success((new subuser_permission_templates_service())->accessModel());
}, [
'list_own_subusers' => 'List chauffeur permission templates for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
'manage_subuser_grants' => 'List chauffeur permission templates for administrative grant management.',
'list_subusers' => 'List chauffeur permission templates for superuser management.',
'add_subusers' => 'List chauffeur permission templates while inviting chauffeurs.',
'edit_subusers' => 'List chauffeur permission templates while editing chauffeur grants.',
]);
$this->post('/subusers', function () {
global /** @var response $response */
$response;
@@ -1150,6 +1587,26 @@ class subusersRoute
'list_subusers' => 'List all chauffeur access grants for superusers.',
]);
$this->get('/superuser/users/{user_id}/subusers', function () {
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->addUserScopedSubuserMeta($targetUser);
$response->success($this->listSuperuserSubusers((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'List chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/superuser/users/{user_id}/subusers/summary', function () {
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'Summarize chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/subusers', function () {
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
@@ -1264,6 +1721,14 @@ class subusersRoute
'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/invite', function () {
$this->requirePermission('add_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->handleInviteSubuserForCustomer((int)$targetUser['customer_number']);
}, [
'add_subusers' => 'Invite or link chauffeurs for a selected superuser customer account.',
]);
$this->post('/subusers/invite', function () {
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
$this->handleInviteSubuserForCustomer($customerNumber);
@@ -1306,6 +1771,28 @@ class subusersRoute
'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend', function () {
$this->requirePermission('edit_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->resendInviteForScopedUser(
$this->routePositiveInt('subuser_id'),
(int)$targetUser['customer_number']
);
}, [
'edit_subusers' => 'Resend chauffeur invites for a selected superuser customer account.',
]);
$this->patch('/superuser/users/{user_id}/subusers/grants/{grant_id}', function () {
$this->requirePermission('manage_subuser_grants');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->patchScopedSubuserGrant(
$this->routePositiveInt('grant_id'),
(int)$targetUser['customer_number']
);
}, [
'manage_subuser_grants' => 'Edit chauffeur grants for a selected superuser customer account.',
]);
$this->post('/subusers/invite/resend', function () {
global $response;
@@ -3,11 +3,14 @@
namespace routes;
use classes\authentication;
use classes\department_customer_pricing_service;
use classes\limited_backoffice_exception;
use objects\branding_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
use objects\products_o;
use objects\users_o;
use traits\route_t;
class superuserDepartmentRoute
@@ -38,9 +41,9 @@ class superuserDepartmentRoute
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT', 'Successfully fetched department');
// Return the department
$response->success(
(new departments_o())->getDepartmentById((int)$this->fromRequest('department_id'))
);
$department = (new departments_o())->getDepartmentById((int)$this->fromRequest('department_id'));
$department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
$response->success($department);
} else {
// Log the incident
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT', 'No user found, or invalid session');
@@ -213,6 +216,57 @@ class superuserDepartmentRoute
'superuser_fetch_department_prices' => 'Fetch department prices'
]);
$this->get('/superuser/department/customer-pricing', function () {
global $response;
$this->requirePermission('superuser_fetch_department_customer_pricing');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$departmentId = $this->positiveIntFromRequest('department_id');
$userId = $this->customerUserIdFromRequest();
try {
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_FETCH_DEPARTMENT_CUSTOMER_PRICING', 'Successfully fetched department customer pricing');
$response->success((new department_customer_pricing_service())->getPricing($departmentId, $userId));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
}, [
'superuser_fetch_department_customer_pricing' => 'Fetch department customer pricing'
]);
$this->put('/superuser/department/customer-pricing', function () {
global $response;
$this->requirePermission('superuser_set_department_customer_pricing');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload)) {
$response->error('Invalid request body', 400);
}
$departmentId = $this->positiveIntFromPayload($payload, 'department_id');
$userId = $this->customerUserIdFromPayload($payload);
try {
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_CUSTOMER_PRICING', 'Successfully set department customer pricing');
$response->success((new department_customer_pricing_service())->updatePricing($departmentId, $userId, $payload));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
}, [
'superuser_set_department_customer_pricing' => 'Set department customer pricing'
]);
$this->get('/superuser/department/variables', function () {
// Require the user to be logged in
global $response;
@@ -295,4 +349,78 @@ class superuserDepartmentRoute
]);
}
private function positiveIntFromRequest(string $name): int
{
global $response;
$value = $this->fromRequest($name);
if (!is_string($value) && !is_int($value)) {
$response->error($name . ' is required', 400);
}
return $this->positiveIntValue($value, $name);
}
private function customerUserIdFromRequest(): int
{
if ($this->fromRequest('user_id') !== null) {
return $this->positiveIntFromRequest('user_id');
}
return $this->userIdFromCustomerNumber($this->positiveIntFromRequest('customer_number'));
}
/**
* @param array<string, mixed> $payload
*/
private function positiveIntFromPayload(array $payload, string $name): int
{
global $response;
if (!array_key_exists($name, $payload)) {
$response->error($name . ' is required', 400);
}
return $this->positiveIntValue($payload[$name], $name);
}
/**
* @param array<string, mixed> $payload
*/
private function customerUserIdFromPayload(array $payload): int
{
if (array_key_exists('user_id', $payload)) {
return $this->positiveIntFromPayload($payload, 'user_id');
}
return $this->userIdFromCustomerNumber($this->positiveIntFromPayload($payload, 'customer_number'));
}
private function positiveIntValue(mixed $value, string $name): int
{
global $response;
if (is_int($value)) {
$parsed = $value;
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$parsed = (int)trim($value);
} else {
$response->error($name . ' must be a positive integer', 400);
}
if ($parsed <= 0) {
$response->error($name . ' must be a positive integer', 400);
}
return $parsed;
}
private function userIdFromCustomerNumber(int $customerNumber): int
{
global $response;
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
return (int)$customer->id;
}
}
@@ -33,10 +33,9 @@ class systemSearchRoute
$response->success([
'message' => 'System search cache cleared',
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_clear' => 'Clear system-wide search query and intent caches',
'superuser_search_system_cache_clear' => 'Clear system-wide search query caches',
]);
$this->post('/superuser/search/system/cache/rebuild', function () {
@@ -49,15 +48,12 @@ class systemSearchRoute
$types = $this->parseTypeList($params['types'] ?? []);
$request = system_search_cache::enqueueRebuild($scope, $types);
// Rebuild endpoint also clears parser namespace immediately.
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$response->success([
'message' => 'System search cache rebuild queued',
'request' => $request,
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild',
@@ -86,9 +82,7 @@ class systemSearchRoute
$includeTypes = $this->parseTypeList($params['include_types'] ?? []);
$excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []);
$includeAssociations = $this->toBool($params['include_associations'] ?? true, true);
$debugIntent = $this->toBool($params['debug_intent'] ?? false, false);
$limit = $this->clampInt((int)($params['limit'] ?? 50), 1, 200, 50);
$offset = max(0, (int)($params['offset'] ?? 0));
$maxResults = $this->clampMaxResults((int)($params['max_results'] ?? 50));
[$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes();
if (empty($allowedTypes)) {
@@ -118,9 +112,7 @@ class systemSearchRoute
'permissions_catalog_own' => $permissionsCatalogOwn,
'module_config_visibility' => $this->buildModuleConfigVisibility(),
'include_associations' => $includeAssociations,
'debug_intent' => $debugIntent,
'limit' => $limit,
'offset' => $offset,
'max_results' => $maxResults,
]);
$response->success($result);
@@ -596,6 +588,11 @@ class systemSearchRoute
return $value;
}
private function clampMaxResults(int $value): int
{
return $this->clampInt($value, 1, 50, 50);
}
private function allEntityTypes(): array
{
return array_keys($this->entityPermissionMap());
+33 -3
View File
@@ -33,9 +33,11 @@ class userRoute
// Return an error
$response->error('User not found', 404);
}
$targetUserData = $targetUser->includeIncludes(['all'])->asArray();
$targetUserData['limited_backoffice_managed'] = (new users_o())->isLimitedBackofficeManagedUser((int)$targetUser->id);
// Return the list of users
$response->success(
$targetUser->includeIncludes(['all'])->asArray()
$targetUserData
);
} else {
// Log the incident
@@ -103,6 +105,9 @@ class userRoute
}
// Check if the required fields are set
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$response->error('Invalid request body', 400);
}
if (!isset($data['discount'])) {
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set');
@@ -122,14 +127,38 @@ class userRoute
$response->error('No is_category set', 400);
}
$discount = (int)$data['discount'];
if ($discount < 0 || $discount > 100) {
$response->error('Discount must be between 0 and 100', 400);
}
$is_category = (bool)$data['is_category'];
if ($is_category) {
$object_id = (string)$data['object_id'];
} else {
$object_id = (int)$data['object_id'];
}
$fixed_price_is_set = array_key_exists('fixed_price', $data);
$fixed_price = null;
if ($fixed_price_is_set) {
if ($data['fixed_price'] === null || $data['fixed_price'] === '') {
$fixed_price = null;
} else {
$fixed_price_value = filter_var($data['fixed_price'], FILTER_VALIDATE_INT);
if ($fixed_price_value === false) {
$response->error('Invalid fixed price', 400);
}
$fixed_price = (int)$fixed_price_value;
}
if ($fixed_price !== null && $fixed_price < 0) {
$response->error('Fixed price must be zero or more', 400);
}
if ($is_category && $fixed_price !== null) {
$response->error('Fixed price can only be set for products', 400);
}
} elseif (!$is_category) {
$fixed_price = $targetUser->getProductFixedPrice((int)$object_id);
}
// Set the custom price
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category);
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category, $fixed_price);
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$targetUser->id,
@@ -145,7 +174,8 @@ class userRoute
'route' => '/superuser/user/discounts',
'method' => 'POST',
'actor_user_id' => (int)$user->id,
]
],
$fixed_price
);
} catch (\Throwable $e) {
(new logs_o())->add(
+93 -12
View File
@@ -27,19 +27,28 @@ class usersRoute
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
// Return the list of users
$users_o = new users_o();
$response->success(
$users_o->parseUsers(
$users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'customer_number',
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet()
)
$limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
$users = $users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'customer_number',
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet(
null,
$limitedEmployeeListMode['filters'],
[],
$limitedEmployeeListMode['additional_where']
);
if ($limitedEmployeeListMode['enabled']) {
$users = $users_o->markLimitedBackofficeManagedUsers($users);
}
$users = $users_o->parseUsers(
$users
);
$response->success($users);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session');
@@ -153,6 +162,23 @@ class usersRoute
if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
$data['display_name'] = null;
}
$targetUser = (new users_o())->getUserById((int)$data['id']);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) {
$currentCustomerNumber = (string)$targetUser->customer_number->value();
if ((string)$data['customer_number'] !== $currentCustomerNumber) {
$response->error('Limited backoffice managed users cannot change customer number.', 403);
}
if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) {
$response->error('Limited backoffice managed users cannot change role.', 403);
}
$data['role'] = null;
}
// If the role is set, require the edit_user_role permission
if ($data['role']) {
$this->requirePermission('edit_user_role');
@@ -207,4 +233,59 @@ class usersRoute
]
);
}
/**
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
*/
private function limitedBackofficeEmployeeListMode(users_o $users): array
{
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if ($filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
'additional_where' => null,
];
}
$filterArray = $users->filter_string_to_array($filters);
$customerNumberFilter = $filterArray['customer_number'] ?? null;
$isEmployeeFilter = $customerNumberFilter === '0'
|| $customerNumberFilter === 0
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) {
// When include mode is on but the filter is not a customer_number:0 query,
// pass the original filter through as forced filters so they are not discarded.
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
// back to reading the filters from the request, which is equivalent.
return [
'enabled' => false,
'filters' => $enabled ? $filters : null,
'additional_where' => null,
];
}
// $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
if (!$enabled) {
// Exclude active limited backoffice employees when the include flag is not set.
return [
'enabled' => false,
'filters' => null,
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
];
}
unset($filterArray['customer_number']);
return [
'enabled' => true,
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
];
}
}
+392
View File
@@ -21,6 +21,311 @@ class vehiclesRoute
{
use route_t;
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function resolveSuperuserVehicleTargetUser(int $userId): array
{
global $response;
$targetUser = (new users_o())->select($userId);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
$targetUser->getObjectProperties();
$customerNumber = (int)$targetUser->customer_number->value();
if ($customerNumber <= 0) {
$response->error('Selected user does not have a customer number', 400);
}
return [
'user_id' => (int)$targetUser->id,
'customer_number' => $customerNumber,
'customer_name' => (string)$targetUser->getCustomerName($customerNumber),
];
}
private function addUserScopedVehicleMeta(array $targetUser): void
{
global $response;
$response->add_meta('user_context', $targetUser);
$response->add_meta('vehicles_summary', $this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
}
private function buildVehiclePayload(array $vehicle): array
{
return [...(new customer_vehicles_o())->select((int)$vehicle['id'])->asArray()];
}
private function listVehiclesForCustomer(int $customerNumber): array
{
$vehicles = new customer_vehicles_o();
return $vehicles->listObjectsWithPaginationIfSet(
fn ($vehicle) => $this->buildVehiclePayload($vehicle),
$vehicles->forceRestrictFilters([
'customer_id' => [$customerNumber],
])
);
}
private function buildVehicleSummaryForCustomer(int $customerNumber): array
{
$vehicles = new customer_vehicles_o();
$filters = [
'customer_id' => $customerNumber,
];
if ($vehicles->columnsExist(['deleted_at'])) {
$filters['deleted_at'] = null;
}
$rows = $vehicles->getFieldsWhere($filters, [
'id',
'wash_subscription',
]);
$summary = [
'total' => 0,
'wash_subscription' => 0,
'self_service' => 0,
];
foreach ($rows as $row) {
$summary['total']++;
if ((int)($row['wash_subscription'] ?? 0) === 1) {
$summary['wash_subscription']++;
}
try {
$vehicle = (new customer_vehicles_o())->select((int)$row['id']);
if ($vehicle->exists() && $vehicle->hasXLVask()) {
$summary['self_service']++;
}
} catch (\Throwable) {
// XLVask availability should not prevent the customer vehicle summary from loading.
}
}
return $summary;
}
private function requireScopedVehicle(int $vehicleId, int $customerNumber): customer_vehicles_o
{
global $response;
$vehicle = (new customer_vehicles_o())->select($vehicleId);
if (!$vehicle->exists()) {
$response->error('Vehicle not found', 404);
}
$vehicle->getObjectProperties();
if ((int)$vehicle->customer_id->value() !== $customerNumber) {
$response->error('Vehicle does not belong to selected user', 404);
}
return $vehicle;
}
private function validateOptionalCustomerIdMatches(int $customerNumber): void
{
global $response;
if (!self::isParametersSet(['customer_id'])) {
return;
}
$requestedCustomerNumber = (int)self::getParameter('customer_id');
self::requireType($requestedCustomerNumber, self::type_int());
if ($requestedCustomerNumber !== $customerNumber) {
$response->error('Customer number does not match selected user', 400);
}
}
private function createVehicleForCustomer(int $customerNumber): array
{
global $response;
self::requireParameters([
'type',
'reg',
]);
$this->validateOptionalCustomerIdMatches($customerNumber);
$reference = null;
if (self::isParametersSet(['reference']) && !empty(self::getParameter('reference'))) {
$reference = (string)self::getParameter('reference');
self::requireType($reference, self::type_string());
self::requireMinLength('reference', 1);
self::requireMaxLength('reference', 255);
}
self::requireType(self::getParameter('reg'), self::type_string());
self::requireType(self::getParameter('type'), self::type_int());
$subscription = false;
if (self::isParametersSet(['wash_subscription'])) {
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
$subscription = (bool)self::getParameter('wash_subscription');
}
$reg = trim((string)self::getParameter('reg'));
self::requireMinLength('reg', 2);
self::requireMaxLength('reg', 12);
$type = (int)self::getParameter('type');
$vehicle = new customer_vehicles_o();
$vehicle->add($customerNumber, $type, $reg, $subscription, $reference);
try {
(new economic_v2_versioning_service())->recordVehicleSubscriptionVersion(
[
'vehicle_id' => (int)$vehicle->id,
'customer_number' => $customerNumber,
'reg' => $reg,
'vehicle_type' => $type,
'wash_subscription' => $subscription,
],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'POST',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
return $vehicle->asArray();
}
private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array
{
global $response;
$this->validateOptionalCustomerIdMatches($customerNumber);
$beforeState = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
if (self::isParametersSet(['type'])) {
$type = (int)self::getParameter('type');
self::requireType($type, self::type_int());
self::requireMinValue($type, 0);
if ($type === 0) {
$vehicle->type->set(0);
$vehicle->wash_subscription->set(0);
} else {
$product = new products_o();
$product->select($type);
if (!$product->exists() || !$product->subscription_allowed->value()) {
$response->error('Invalid type', 400);
}
$vehicle->type->set($type);
}
}
if (self::isParametersSet(['reg'])) {
$reg = (string)self::getParameter('reg');
self::requireType($reg, self::type_string());
self::requireMinLength('reg', 2);
self::requireMaxLength('reg', 12);
$vehicle->reg->set(preg_replace('/\s+/', '', $reg));
}
if (self::isParametersSet(['wash_subscription'])) {
$subscription = (bool)self::getParameter('wash_subscription');
self::requireType($subscription, self::type_bool());
if ((int)$vehicle->type->value() === 0 && $subscription) {
$response->error('Unable to set subscription, type is not set', 400);
}
$vehicle->wash_subscription->set($subscription ? 1 : 0);
}
if (self::isParametersSet(['reference'])) {
if (empty(self::getParameter('reference'))) {
$vehicle->reference->nullify();
} else {
$reference = (string)self::getParameter('reference');
self::requireType($reference, self::type_string());
self::requireMinLength('reference', 1);
self::requireMaxLength('reference', 255);
$vehicle->reference->set($reference);
}
}
$vehicle->objectChanged();
$afterState = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
$versionRelevantChange = (
(string)$beforeState['reg'] !== (string)$afterState['reg'] ||
(int)$beforeState['vehicle_type'] !== (int)$afterState['vehicle_type'] ||
(bool)$beforeState['wash_subscription'] !== (bool)$afterState['wash_subscription']
);
if ($versionRelevantChange) {
try {
$versioning = new economic_v2_versioning_service();
$effectiveAt = date('Y-m-d H:i:s');
if ((string)$beforeState['reg'] !== (string)$afterState['reg']) {
$versioning->closeActiveVehicleSubscriptionVersion(
(int)$beforeState['customer_number'],
(string)$beforeState['reg'],
$effectiveAt,
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
'reason' => 'identity_change',
]
);
}
$versioning->recordVehicleSubscriptionVersion(
$afterState,
$effectiveAt,
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
}
return $vehicle->asArray();
}
public function run(): void
{
$this->get('/vehicles', function () {
@@ -119,6 +424,93 @@ class vehiclesRoute
]
);
$this->get('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('list_vehicles_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$this->addUserScopedVehicleMeta($targetUser);
$response->success($this->listVehiclesForCustomer((int)$targetUser['customer_number']));
}, [
'list_vehicles_other' => 'List vehicles for a selected superuser customer account.',
]);
$this->get('/superuser/users/{user_id}/vehicles/summary', function () {
global $response;
$this->requirePermission('list_vehicles_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
}, [
'list_vehicles_other' => 'Summarize vehicles for a selected superuser customer account.',
]);
$this->post('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('add_vehicle_other');
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->createVehicleForCustomer((int)$targetUser['customer_number']));
}, [
'add_vehicle_other' => 'Add a vehicle for a selected superuser customer account.',
]);
$this->put('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('edit_vehicle_other');
self::requireParameters(['id']);
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$vehicleId = (int)self::getParameter('id');
self::requireType($vehicleId, self::type_int());
self::requireMinValue($vehicleId, 1);
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
$response->add_meta('user_context', $targetUser);
$response->success($this->updateScopedVehicle($vehicle, (int)$targetUser['customer_number']));
}, [
'edit_vehicle_other' => 'Edit a vehicle for a selected superuser customer account.',
]);
$this->delete('/superuser/users/{user_id}/vehicles', function () {
global $response;
$this->requirePermission('delete_vehicle_other');
self::requireParameters(['id']);
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
$vehicleId = (int)self::getParameter('id');
self::requireType($vehicleId, self::type_int());
self::requireMinValue($vehicleId, 1);
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
$beforeState = [
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
];
$vehicle->delete();
try {
(new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion(
(int)$beforeState['customer_number'],
(string)$beforeState['reg'],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/superuser/users/{user_id}/vehicles',
'method' => 'DELETE',
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
]
);
} catch (\Throwable $exception) {
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
$response->add_meta('user_context', $targetUser);
$response->success([
'success' => true,
'message' => 'Vehicle deleted successfully',
]);
}, [
'delete_vehicle_other' => 'Delete a vehicle for a selected superuser customer account.',
]);
$this->post('/vehicles', function () {
global $response;
$auth = new authentication();
@@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` (
`is_category` TINYINT(1) NOT NULL,
`object_id` VARCHAR(64) NOT NULL,
`discount` INT NOT NULL,
`fixed_price` INT NULL DEFAULT NULL,
`effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
usesApiSuite();
function wash_certificate_download_legacy_booking(int $customerNumber, int $departmentId, array $attributes = []): array
{
return api_fixtures()->createLegacyBooking(array_merge([
'customer_number' => $customerNumber,
'department' => $departmentId,
'washCertificateStatus' => 'pending',
'status' => 'pending',
], $attributes));
}
it('lets customer accounts reach their own wash certificate download without the download permission', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Customer Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$session['user']['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Wash certificate has not been issued yet');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('keeps customer wash certificate downloads scoped to their own bookings', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Wash Certificate Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Other Wash Certificate Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('lets customer accounts reach the legacy wash certificate pdf download gate', function (): void {
api_test_covers('GET /bookings/download_pdf', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Legacy PDF Other Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Legacy PDF Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->get(
'/bookings/download_pdf?id=' . (int)$booking['id'],
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
usesApiSuite();
function bulk_action_order_item_deleted_at(int $orderItemId): ?string
{
$row = api_test_runtime()->queryOne('SELECT deleted_at FROM order_items WHERE id = ' . $orderItemId . ' LIMIT 1');
return $row['deleted_at'] ?? null;
}
function bulk_action_order_invoice_collection_id(int $orderId): int
{
$row = api_test_runtime()->queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
return (int)($row['invoice_collection_id'] ?? 0);
}
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$product = api_fixtures()->createProduct([
'name' => 'Spot Free rinse',
'price' => 80,
]);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => 1,
'price' => 80,
]);
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'locale' => 'da',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['preview_id'] ?? null)->toBeString()
->and($preview['confirmation_phrase'] ?? null)->toBe('Bekræft')
->and($preview['summary']['changed_count'] ?? null)->toBe(1)
->and($preview['order_items'][0]['order_item_id'] ?? null)->toBe((int)$orderItem['id'])
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekraeft',
'locale' => 'da',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
expect(bulk_action_order_item_deleted_at((int)$orderItem['id']))->toBeNull();
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Bekræft',
'locale' => 'da',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$applied = $applyResponse->data();
expect($applied['preview'] ?? null)->toBeFalse()
->and($applied['result']['changed_count'] ?? null)->toBe(1)
->and(bulk_action_order_item_deleted_at((int)$orderItem['id']))->not->toBeNull();
});
it('previews and applies customer rule cleanup for both spotfree addon products', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup-spotfree-addons');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup-spotfree-addons');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Cleanup Spotfree Addons Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
$department = api_fixtures()->createDepartment();
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $invoiceCollection['id'],
]);
$spotfreeVanProduct = api_fixtures()->createProduct([
'id' => 23,
'name' => 'Skylning med RO - Varevogn',
'category' => 4,
'price' => 39,
]);
$spotfreeTruckProduct = api_fixtures()->createProduct([
'id' => 24,
'name' => 'Skylning med RO - Lastbil',
'category' => 4,
'price' => 39,
]);
$vanOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeVanProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$truckOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeTruckProduct['id'],
'cashier_id' => 1,
'price' => 39,
]);
$session = api_fixtures()->createUserSession(['reset_collected_invoice_economic']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
$previewOrderItemIds = array_map('intval', array_column($preview['order_items'] ?? [], 'order_item_id'));
sort($previewOrderItemIds);
expect($preview['summary']['changed_count'] ?? null)->toBe(2)
->and($previewOrderItemIds)->toBe([(int)$vanOrderItem['id'], (int)$truckOrderItem['id']])
->and(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->toBeNull();
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'remove_customer_rule_violations',
'invoice_collection_ids' => [$invoiceCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_item_deleted_at((int)$vanOrderItem['id']))->not->toBeNull()
->and(bulk_action_order_item_deleted_at((int)$truckOrderItem['id']))->not->toBeNull();
});
it('merges selected invoice collections into the explicit target after confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'merge');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'merge');
$customer = api_fixtures()->createUser(['display_name' => 'Bulk Merge Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$sourceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$targetOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
]);
$sourceOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $sourceCollection['id'],
]);
$session = api_fixtures()->createUserSession(['move_collected_invoice']);
$previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'locale' => 'en',
], $session['headers']);
$previewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$preview = $previewResponse->data();
expect($preview['confirmation_phrase'] ?? null)->toBe('Confirm')
->and($preview['target_invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
->and($preview['summary']['orders_to_move'] ?? null)->toBe(1)
->and(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$sourceCollection['id']);
$applyResponse = api_client()->post('/collected-invoices/bulk-actions/apply', [
'preview_id' => $preview['preview_id'],
'action' => 'merge_collections',
'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']],
'options' => ['target_invoice_collection_id' => $targetCollection['id']],
'confirmation_text' => 'Confirm',
'locale' => 'en',
], $session['headers']);
$applyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id'])
->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id']);
});
@@ -85,6 +85,65 @@ it('previews monthly split changes without moving orders or creating collections
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
});
it('previews only explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'preview-scope');
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Preview Monthly Split Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$ignoredCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$targetMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-03-15 10:00:00',
]);
$targetAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-04-02 10:00:00',
]);
$ignoredMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-03-16 10:00:00',
]);
$ignoredAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-04-03 10:00:00',
]);
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
$response = api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [$targetCollection['id']],
'preview' => true,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
expect($payload['processed_count'] ?? null)->toBe(1)
->and($payload['changed_count'] ?? null)->toBe(1)
->and($payload['changed'][0]['invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
});
it('splits a selected March and April collected invoice into monthly collections', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
@@ -139,6 +198,74 @@ it('splits a selected March and April collected invoice into monthly collections
}
});
it('splits only explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'scope');
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
'created_at' => '2096-03-01 00:00:01',
]);
$ignoredCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
'created_at' => '2096-03-01 00:00:01',
]);
$targetMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-03-15 10:00:00',
]);
$targetAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-04-02 10:00:00',
]);
$ignoredMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-03-16 10:00:00',
]);
$ignoredAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-04-03 10:00:00',
]);
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
$createdCollectionIds = [];
try {
$response = api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [$targetCollection['id']],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
$createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []);
$aprilCollectionId = (int)($createdCollectionIds[0] ?? 0);
expect($payload['processed_count'] ?? null)->toBe(1)
->and($payload['changed_count'] ?? null)->toBe(1)
->and($aprilCollectionId)->toBeGreaterThan(0)
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId)
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
} finally {
monthly_split_cleanup_collections($createdCollectionIds);
}
});
it('sets closed_at to month end when split month has ended', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
@@ -345,3 +472,27 @@ it('rejects invalid monthly split date ranges', function (): void {
->assertEnvelope()
->assertSuccess(false);
});
it('rejects invalid explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'invalid-scope');
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => ['not-a-number'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lets customer booking sessions read their own customer attributes', function (): void {
api_test_covers('GET /customer/attributes', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$session['user']['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$attributes = array_map(
static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''),
is_array($response->data()) ? $response->data() : []
);
expect($attributes)->toContain('onlyTankCleaning');
expect($response->body)->not->toContain('list_customer_attributes');
});
it('keeps customer attribute reads scoped to the authenticated customer', function (): void {
api_test_covers('GET /customer/attributes', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Attribute Customer']);
api_fixtures()->addCustomerAttribute((int)$otherCustomer['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$otherCustomer['customer_number'],
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['list_customer_attributes']);
});
it('still lets attribute managers read another customer attributes', function (): void {
api_test_covers('GET /customer/attributes', 'permissions');
$session = api_fixtures()->createUserSession(['list_customer_attributes']);
$customer = api_fixtures()->createUser(['display_name' => 'Managed Attribute Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$attributes = array_map(
static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''),
is_array($response->data()) ? $response->data() : []
);
expect($attributes)->toContain('onlyTankCleaning');
});
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
use classes\limited_backoffice_service;
usesApiSuite();
function department_customer_pricing_price_insert(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
function department_customer_pricing_setup(array $departmentAttributes = []): array
{
$department = api_fixtures()->createDepartment([
'name' => 'Scoped Customer Pricing Department',
'custom_pricing_only' => 1,
...$departmentAttributes,
]);
$category = api_fixtures()->createCategory(['name' => 'Scoped Customer Pricing Category']);
$product = api_fixtures()->createProduct([
'name' => 'Scoped Customer Pricing Product',
'category' => $category['id'],
'price' => 1000,
'apply_category_discount' => 1,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
department_customer_pricing_price_insert((int)$department['id'], (int)$product['id'], 1000);
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Customer Pricing Customer']);
return [
'department' => $department,
'category' => $category,
'product' => $product,
'customer' => $customer,
];
}
it('rejects department customer pricing when custom-only pricing is disabled', function (): void {
api_test_covers('GET /superuser/department/customer-pricing', 'validation');
$fixture = department_customer_pricing_setup(['custom_pricing_only' => 0]);
$session = api_fixtures()->createUserSession(['superuser_fetch_department_customer_pricing']);
$response = api_client()->get(
'/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] .
'&user_id=' . (int)$fixture['customer']['id'],
$session['headers']
);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
expect($response->data()['code'] ?? null)->toBe('department_customer_pricing_disabled');
});
it('sets and applies department-specific customer discounts without legacy fallback', function (): void {
api_test_covers('GET /superuser/department/customer-pricing', 'happy');
api_test_covers('PUT /superuser/department/customer-pricing', 'happy');
api_test_covers('GET /products', 'pricing');
$fixture = department_customer_pricing_setup();
api_fixtures()->createPriceOverride([
'user_id' => $fixture['customer']['id'],
'is_category' => 0,
'product_or_category_id' => (string)$fixture['product']['id'],
'percentage' => 80,
]);
$session = api_fixtures()->createUserSession([
'superuser_fetch_department_customer_pricing',
'superuser_set_department_customer_pricing',
'list_products',
'department_access_' . (int)$fixture['department']['id'],
]);
$updated = api_client()->put('/superuser/department/customer-pricing', [
'department_id' => $fixture['department']['id'],
'user_id' => $fixture['customer']['id'],
'overrides' => [
[
'is_category' => false,
'product_or_category_id' => $fixture['product']['id'],
'discount' => 25,
'fixed_price' => null,
],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(25);
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(750);
$byCustomerNumber = api_client()->get(
'/superuser/department/customer-pricing?department_id=' . (int)$fixture['department']['id'] .
'&customer_number=' . (int)$fixture['customer']['customer_number'],
$session['headers']
);
$byCustomerNumber
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($byCustomerNumber->data()['customer']['id'] ?? null)->toBe((int)$fixture['customer']['id']);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . (int)$fixture['product']['id'] .
'&department_id=' . (int)$fixture['department']['id'] .
'&customer_id=' . (int)$fixture['customer']['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($productResponse->data()['price'] ?? 0))->toBe(750);
});
it('limits department customer pricing to assigned limited-backoffice departments', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
$fixture = department_customer_pricing_setup();
$otherFixture = department_customer_pricing_setup(['name' => 'Denied Scoped Customer Pricing Department']);
$session = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
]);
api_client()
->get(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] .
'/customer-pricing?user_id=' . (int)$fixture['customer']['id'],
api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
'department_access_' . (int)$fixture['department']['id'],
])['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING]);
api_client()
->get(
'/limited-backoffice/departments/' . (int)$otherFixture['department']['id'] .
'/customer-pricing?user_id=' . (int)$otherFixture['customer']['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherFixture['department']['id']]);
api_client()
->put(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing',
[
'user_id' => $fixture['customer']['id'],
'overrides' => [],
],
api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
])['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING]);
$updated = api_client()->put(
'/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing',
[
'user_id' => $fixture['customer']['id'],
'overrides' => [
[
'is_category' => true,
'product_or_category_id' => (string)$fixture['category']['id'],
'discount' => 30,
],
],
],
$session['headers']
);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(30);
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(700);
});
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
usesApiSuite();
function daily_report_product_from_overview(array $overview, int $productId): array
{
foreach (($overview['products'] ?? []) as $product) {
if ((int)($product['product_id'] ?? 0) === $productId) {
return $product;
}
}
return [];
}
it('stores clears and permission-gates department daily report product targets', function (): void {
api_test_covers('PUT /departments/daily-reports/product-targets', 'happy');
api_test_covers('GET /departments/daily-reports/overview', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Daily Report Product Target ' . uniqid('', false),
]);
$departmentId = (int)$department['id'];
$editorPermissions = [
'list_department_daily_reports',
'list_bookings',
'set_department_daily_report_product_targets',
'department_access_' . $departmentId,
];
$editorSession = api_fixtures()->createUserSession($editorPermissions);
$viewerSession = api_fixtures()->createUserSession([
'list_department_daily_reports',
'list_bookings',
'department_access_' . $departmentId,
]);
try {
$saveResponse = api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 47.55,
], $editorSession['headers']);
$saveResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($saveResponse->data())->toMatchArray([
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 47.6,
]);
$overviewResponse = api_client()->get(
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
$editorSession['headers']
);
$overviewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$editorProduct = daily_report_product_from_overview($overviewResponse->data(), 24);
expect($editorProduct['target_percentage'])->toBe(47.6);
expect($editorProduct['target_department_id'])->toBe($departmentId);
$viewerOverviewResponse = api_client()->get(
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
$viewerSession['headers']
);
$viewerOverviewResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$viewerProduct = daily_report_product_from_overview($viewerOverviewResponse->data(), 24);
expect($viewerProduct['target_percentage'])->toBeNull();
expect($viewerProduct['target_department_id'])->toBeNull();
$clearResponse = api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => null,
], $editorSession['headers']);
$clearResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($clearResponse->data())->toMatchArray([
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => null,
]);
$clearedOverviewResponse = api_client()->get(
'/departments/daily-reports/overview?date=2026-07-06&department_ids=' . $departmentId,
$editorSession['headers']
);
$clearedProduct = daily_report_product_from_overview($clearedOverviewResponse->data(), 24);
expect($clearedProduct['target_percentage'])->toBeNull();
expect($clearedProduct['target_department_id'])->toBeNull();
} finally {
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => null,
], $editorSession['headers']);
}
});
it('rejects product target updates without permission access or valid input', function (): void {
api_test_covers('PUT /departments/daily-reports/product-targets', 'auth');
api_test_covers('PUT /departments/daily-reports/product-targets', 'failure');
$department = api_fixtures()->createDepartment();
$departmentId = (int)$department['id'];
$missingPermissionSession = api_fixtures()->createUserSession([
'department_access_' . $departmentId,
]);
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 50,
], $missingPermissionSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['set_department_daily_report_product_targets']);
$missingDepartmentAccessSession = api_fixtures()->createUserSession([
'set_department_daily_report_product_targets',
]);
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 50,
], $missingDepartmentAccessSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . $departmentId]);
$editorSession = api_fixtures()->createUserSession([
'set_department_daily_report_product_targets',
'department_access_' . $departmentId,
]);
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 999999,
'target_percentage' => 50,
], $editorSession['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid daily report product_id');
api_client()->put('/departments/daily-reports/product-targets', [
'department_id' => $departmentId,
'product_id' => 24,
'target_percentage' => 101,
], $editorSession['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Parameter target_percentage must be between 0 and 100');
});
@@ -163,6 +163,39 @@ it('rejects department listing when the permission is missing', function (): voi
->assertMissingPermissions(['list_departments']);
});
it('returns superuser department custom pricing state as a boolean', function (): void {
$session = api_fixtures()->createUserSession(['superuser_fetch_department']);
$fallbackDepartment = api_fixtures()->createDepartment([
'name' => 'Fallback Pricing Department',
'custom_pricing_only' => 0,
]);
$customOnlyDepartment = api_fixtures()->createDepartment([
'name' => 'Custom Only Pricing Department',
'custom_pricing_only' => 1,
]);
$fallbackResponse = api_client()->get(
'/superuser/department?department_id=' . $fallbackDepartment['id'],
$session['headers']
);
$customOnlyResponse = api_client()->get(
'/superuser/department?department_id=' . $customOnlyDepartment['id'],
$session['headers']
);
$fallbackResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$customOnlyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($fallbackResponse->data()['custom_pricing_only'] ?? null)->toBeFalse();
expect($customOnlyResponse->data()['custom_pricing_only'] ?? null)->toBeTrue();
});
it('creates departments through the real endpoint', function (): void {
api_test_covers('POST /departments', 'happy');
@@ -231,6 +264,7 @@ it('updates departments through the real endpoint', function (): void {
'description' => 'Updated description',
'order_priority' => 5,
'archived' => true,
'custom_pricing_only' => true,
], $session['headers']);
$response
@@ -246,6 +280,7 @@ it('updates departments through the real endpoint', function (): void {
expect($row['description'] ?? null)->toBe('Updated description');
expect((int)($row['order_priority'] ?? 0))->toBe(5);
expect((int)($row['archived'] ?? 0))->toBe(1);
expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1);
});
it('rejects invalid department update requests', function (): void {
@@ -299,6 +334,42 @@ it('lists department categories for a department', function (): void {
->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']);
});
it('lets customer booking sessions list department categories without the management permission', function (): void {
api_test_covers('GET /departments/categories', 'auth');
$customerSession = api_fixtures()->createUserSession(['user']);
$department = api_fixtures()->createDepartment();
$category = api_fixtures()->createCategory([
'name' => 'Customer Department Category',
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
$customerResponse = api_client()->get('/departments/categories?id=' . $department['id'], $customerSession['headers']);
$customerResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($customerResponse->data())
->toBeArray()
->toHaveCount(1)
->and($customerResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
$subuserSession = api_fixtures()->createSubuserSession((int)$customerSession['user']['customer_number'], []);
$subuserResponse = api_client()->get('/departments/categories?id=' . $department['id'], $subuserSession['headers']);
$subuserResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($subuserResponse->data())
->toBeArray()
->toHaveCount(1)
->and($subuserResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']);
});
it('rejects invalid department category requests', function (): void {
api_test_covers('GET /departments/categories', 'failure');
@@ -163,6 +163,22 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
->toHaveKey('session_type', 'gateway-stream')
->toHaveKey('gateway_id', (int)$gateway['id']);
api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
[
'status' => 'connected',
'connection_id' => 'broker-presence-1',
'metadata' => [
'transport' => 'ws',
'refreshed_for' => 'shell-session',
],
],
edge_test_broker_headers()
)
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
['reason' => 'Broker shell validation'],
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
usesApiSuite();
function error_report_api_payload(array $overrides = []): array
{
return array_replace_recursive([
'before_error' => 'Opening the orders page',
'expected' => 'The orders should load',
'actual' => 'The page showed an error',
'data_collection_accepted' => true,
'data_collection_policy_version' => 'error-report-v1',
'route_path' => '/admin/orders',
'page_url' => 'https://app.example.test/admin/orders',
'release_trace_id' => 'trace-error-report-test',
'frontend_version' => 'frontend-test',
'api_version' => 'api-test',
'request_errors' => [
['method' => 'GET', 'url' => '/orders', 'statusCode' => 500],
],
'vue_errors' => [
['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']],
],
'context' => [
'viewport' => ['width' => 1280, 'height' => 720],
'user_agent' => 'ErrorReportsApiTest',
'captured_at' => '2026-07-06T10:00:00.000Z',
'data_collection_policy_version' => 'error-report-v1',
],
], $overrides);
}
function error_report_api_cleanup(array $report): void
{
$id = (int)($report['id'] ?? 0);
if ($id > 0) {
api_fixtures()->cleanupDeleteById('error_reports', $id);
}
}
it('creates error reports when screenshot capture failed', function (): void {
api_test_covers('POST /error-reports', 'happy');
$session = api_fixtures()->createUserSession();
$response = api_client()->post('/error-reports', error_report_api_payload([
'screenshot' => null,
'context' => [
'screenshot_attachment' => ['status' => 'capture_failed'],
],
]), $session['headers']);
$response
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$report = $response->data();
expect($report['screenshot'])->toBeNull();
expect($report['answers']['before_error'])->toBe('Opening the orders page');
expect($report['request_error_count'])->toBe(1);
expect($report['vue_error_count'])->toBe(1);
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
'status' => 'capture_failed',
'attached' => false,
'mime_type' => null,
'size_bytes' => 0,
]);
error_report_api_cleanup($report);
});
it('creates error reports when an optional screenshot payload is invalid', function (): void {
api_test_covers('POST /error-reports', 'invalid optional screenshot');
$session = api_fixtures()->createUserSession();
$response = api_client()->post('/error-reports', error_report_api_payload([
'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'),
]), $session['headers']);
$response
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$report = $response->data();
expect($report['screenshot'])->toBeNull();
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
'status' => 'invalid',
'attached' => false,
'mime_type' => null,
'size_bytes' => 0,
]);
error_report_api_cleanup($report);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
usesApiSuite();
function order_booking_create_payload(array $customer, array $department, array $product, string $reference): array
{
return [
'customer_number' => (int)$customer['customer_number'],
'department' => (int)$department['id'],
'reg_1' => $reference,
'datetime' => '2026-07-07 10:00:00',
'note' => '',
'reference' => $reference,
'po' => '',
'pickup' => false,
'items' => [
[
'id' => (int)$product['id'],
'quantity' => 1,
],
],
];
}
function order_booking_create_department(string $name): array
{
$branding = api_fixtures()->createBranding([
'name' => $name . ' Brand',
'address' => 'API Booking Street 1',
]);
return api_fixtures()->createDepartment([
'name' => $name,
'branding' => (int)$branding['id'],
]);
}
function order_booking_create_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
}
it('lets customers create their own order bookings without booking permissions', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$session = api_fixtures()->createUserSession(['user']);
$department = order_booking_create_department('Own Booking Department');
$product = api_fixtures()->createProduct(['name' => 'Own Booking Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($session['user'], $department, $product, 'OWNBOOK1'),
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
expect($row)->not->toBeNull();
expect((int)($row['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('normalizes booking item prices from server-side customer and department pricing', function (): void {
api_test_covers('POST /order-bookings', 'pricing');
$session = api_fixtures()->createUserSession(['user']);
$department = order_booking_create_department('Own Booking Pricing Department');
$product = api_fixtures()->createProduct([
'name' => 'Booking Price Normalized Product',
'price' => 0,
'is_wash' => 0,
'display_in_booking_form' => 1,
]);
order_booking_create_department_price((int)$department['id'], (int)$product['id'], 425);
$payload = order_booking_create_payload($session['user'], $department, $product, 'PRICEFIX1');
$payload['items'][0]['price'] = 0;
$response = api_client()->post('/order-bookings', $payload, $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$responseItems = $response->data()['items'] ?? [];
expect($responseItems)
->toBeArray()
->and((int)($responseItems[0]['price'] ?? 0))->toBe(425);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
$storedItems = json_decode((string)($row['items'] ?? '[]'), true);
expect($storedItems)
->toBeArray()
->and((int)($storedItems[0]['price'] ?? 0))->toBe(425);
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => (int)$department['id'],
'product_id' => (int)$product['id'],
]);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('blocks subusers creating own customer order bookings without the bookings add node', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']);
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], []);
$department = order_booking_create_department('Subuser Booking Department');
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($customer, $department, $product, 'SUBBOOK1'),
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_own_bookings']);
});
it('lets subusers create own customer order bookings with the bookings add node', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer With Add']);
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], ['BOOKINGS_ADD']);
$department = order_booking_create_department('Subuser Booking Add Department');
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Add Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($customer, $department, $product, 'SUBBOOK2'),
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
expect($row)->not->toBeNull();
expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('still requires elevated access for creating another customer order booking', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']);
$product = api_fixtures()->createProduct(['name' => 'Other Booking Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'),
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_bookings']);
});
it('lets department-scoped users create order bookings for another customer', function (): void {
api_test_covers('POST /order-bookings', 'happy');
$customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']);
$department = order_booking_create_department('Department Scoped Booking Department');
$product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']);
$session = api_fixtures()->createUserSession([
'add_bookings',
'department_access_' . $department['id'],
]);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'),
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
@@ -4,6 +4,73 @@ declare(strict_types=1);
usesApiSuite();
function create_order_item_rule_fixture(array $customerAttributes = []): array
{
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']);
foreach ($customerAttributes as $attribute) {
api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute);
}
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'RULE-CHECK',
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
return [
'customer' => $customer,
'department' => $department,
'order' => $order,
'session' => $session,
];
}
function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse
{
return api_client()->post('/order/items', array_merge([
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $overrides), $headers);
}
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
VALUES (?, 0, ?, ?)'
);
$productIdText = (string)$productId;
$statement->bind_param('isi', $userId, $productIdText, $percentage);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('price_overrides', [
'user_id' => $userId,
'is_category' => 0,
'product_or_category_id' => $productIdText,
]);
}
function custom_pricing_only_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
api_test_covers('POST /order/items', 'validation');
@@ -15,7 +82,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
'reference' => 'NOTE-REQUIRED',
]);
$product = api_fixtures()->createProduct([
'id' => 902701,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
@@ -49,6 +115,146 @@ it('requires notes when adding the extraordinary chemistry product to an order',
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
});
it('uses a product fixed price instead of the best discount when adding an order item', function (): void {
api_test_covers('POST /order/items', 'pricing');
api_test_covers('GET /products', 'pricing');
$customer = api_fixtures()->createUser(['display_name' => 'Fixed Price Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Fixed Price Cashier']);
$category = api_fixtures()->createCategory(['name' => 'Fixed Price Category']);
$product = api_fixtures()->createProduct([
'name' => 'Fixed Price Product',
'price' => 1000,
'category' => $category['id'],
'apply_category_discount' => 1,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 1,
'product_or_category_id' => (string)$category['id'],
'percentage' => 80,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 0,
'product_or_category_id' => (string)$product['id'],
'percentage' => 10,
'fixed_price' => 350,
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'FIXED-PRICE',
]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products', 'department_access_' . $department['id']]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->data()['price'] ?? null)->toBe(350);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['price'] ?? null)->toBe(350);
});
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
api_test_covers('POST /order/items', 'customer_rules');
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'ONLY-TANK',
]);
$washProduct = api_fixtures()->createProduct([
'name' => 'Forvogn',
'price' => 649,
'category' => 4,
]);
$tankCleaningProduct = api_fixtures()->createProduct([
'name' => 'Saebe/kemi, 1-4 spulehoveder',
'price' => 299,
'category' => 5,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $washProduct['id'],
'quantity' => 1,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $tankCleaningProduct['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
});
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
api_test_covers('POST /order/items', 'customer_rules');
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'REGULAR-WASH',
]);
$washProduct = api_fixtures()->createProduct([
'name' => 'Forvogn',
'price' => 649,
'category' => 4,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $washProduct['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
});
it('does not allow clearing notes for order items whose product requires notes', function (): void {
api_test_covers('PUT /order/items', 'validation');
@@ -63,7 +269,7 @@ it('does not allow clearing notes for order items whose product requires notes',
]);
$product = api_fixtures()->createProduct([
'id' => 902702,
'name' => 'API Note Required Product',
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 199,
'requires_note' => 1,
]);
@@ -75,7 +281,7 @@ it('does not allow clearing notes for order items whose product requires notes',
'quantity' => 1,
'notes' => 'Initial note',
]);
$session = api_fixtures()->createUserSession(['edit_order_items']);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client()
->put('/order/items', [
@@ -95,7 +301,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
api_test_covers('GET /products', 'happy');
$product = api_fixtures()->createProduct([
'id' => 902703,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
@@ -111,3 +316,202 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
expect($response->data()['requires_note'] ?? null)->toBeTrue();
});
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Drying add-on',
'category' => 4,
'price' => 50,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'notes' => 'Addon customer rule check',
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture();
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary unrestricted truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Unrestricted add-on',
'category' => 4,
'price' => 50,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('blocks related addon order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary related truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Related extra brush',
'price' => 35,
]);
$primaryItem = api_fixtures()->createOrderItem([
'order_id' => $fixture['order']['id'],
'product_id' => $primaryProduct['id'],
'cashier_id' => $cashier['id'],
'price' => 200,
]);
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'related_item_id' => $primaryItem['id'],
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture([$attribute]);
$product = api_fixtures()->createProduct($productAttributes);
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
})->with([
'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]],
'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]],
'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]],
]);
it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['onlyTankCleaning']);
$nonTankProduct = api_fixtures()->createProduct([
'name' => 'Exterior truck wash',
'price' => 180,
]);
$tankProduct = api_fixtures()->createProduct([
'name' => 'Tank cleaning',
'category' => 5,
'price' => 300,
]);
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
api_test_covers('GET /products', 'happy');
api_test_covers('POST /order/items', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Custom Pricing Products',
'custom_pricing_only' => 1,
]);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
$product = api_fixtures()->createProduct([
'name' => 'Custom Pricing Missing Product',
'category' => $category['id'],
'price' => 12345,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
$session = api_fixtures()->createUserSession([
'list_products',
'add_order_items',
'department_access_' . (int)$department['id'],
]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id']
. '&customer_id=' . (int)$customer['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->body)->not->toContain('12345');
expect($productResponse->body)->not->toContain('3333');
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$otherDepartment['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'CUSTOM-ONLY-ORDER',
]);
$orderItem = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$orderItem
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
});
@@ -73,7 +73,7 @@ it('creates orders through the orders endpoint', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Order Create Department']);
$session = api_fixtures()->createUserSession(['add_order']);
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
$response = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
@@ -128,7 +128,7 @@ it('defaults order PO only from a matching active booking', function (): void {
'po' => 'DELETED-BOOKING-PO',
'deleted_at' => date('Y-m-d H:i:s'),
]);
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [
$session = api_fixtures()->createUserSession(['add_order', 'edit_order', 'department_access_' . $department['id']], [
'customer_number' => $customer['customer_number'],
]);
@@ -149,7 +149,7 @@ it('defaults order PO only from a matching active booking', function (): void {
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [
$unauthorizedSession = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']], [
'customer_number' => $otherCustomer['customer_number'],
]);
$unauthorizedResponse = api_client()->post('/orders', [
@@ -233,7 +233,7 @@ it('rejects invalid order creation requests', function (): void {
$customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment();
$session = api_fixtures()->createUserSession(['add_order']);
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
@@ -262,7 +262,7 @@ it('updates orders through the primary and legacy endpoints', function (): void
'notes' => 'Before update',
'reg_1' => 'BEFORE1',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
$session = api_fixtures()->createUserSession(['edit_order', 'department_access_' . $department['id']]);
api_client()->put('/orders', [
'id' => $order['id'],
@@ -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();
});
it('requires subuser management access before exposing simplified permission templates', function (): void {
api_test_covers('GET /subusers/permission-templates', 'auth');
api_test_covers('GET /subusers/permission-templates', 'happy');
$unauthenticated = api_client()->get('/subusers/permission-templates');
$unauthenticated
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false);
$session = api_fixtures()->createUserSession(['list_own_subusers']);
$authorized = api_client()->get('/subusers/permission-templates', $session['headers']);
$authorized
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($authorized->data()['templates'] ?? null)->toBeArray()->not->toBeEmpty();
expect(array_column($authorized->data()['templates'], 'key'))
->toContain('driver')
->toContain('booking_coordinator')
->toContain('fleet_admin')
->toContain('deactivated');
expect($authorized->data()['groups'] ?? null)->toBeArray()->not->toBeEmpty();
});
it('rejects customer subuser listing without own-scope permission', function (): void {
api_test_covers('GET /subusers', 'auth');
@@ -202,6 +228,11 @@ it('lists chauffeur grants across customers for superusers', function (): void {
(int)$secondCustomer['customer_number'],
['BOOKINGS_LIST']
);
$sharedGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$secondCustomer['customer_number'],
['ORDERS_LIST']
);
$response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']);
@@ -213,22 +244,26 @@ it('lists chauffeur grants across customers for superusers', function (): void {
$rows = array_values(array_filter(
is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item)
&& in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true)
&& in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true)
));
expect($rows)->toHaveCount(2);
$byGrantId = [];
$bySubuserId = [];
foreach ($rows as $row) {
$byGrantId[(int)$row['grant_id']] = $row;
$bySubuserId[(int)$row['id']] = $row;
}
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha');
expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']);
expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']);
expect($bySubuserId[(int)$firstSubuser['id']]['grant_count'])->toBe(2);
expect(array_column($bySubuserId[(int)$firstSubuser['id']]['grants'], 'grant_id'))
->toContain($firstGrantId)
->toContain($sharedGrantId);
expect($bySubuserId[(int)$firstSubuser['id']]['customer_numbers'])
->toContain((int)$firstCustomer['customer_number'])
->toContain((int)$secondCustomer['customer_number']);
expect($bySubuserId[(int)$secondSubuser['id']]['grant_count'])->toBe(1);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['grant_id'])->toBe($secondGrantId);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['customer_name'])->toBe('Fleet Customer Beta');
});
it('lets superusers invite chauffeurs for a selected customer', function (): void {
@@ -280,3 +315,275 @@ it('lets superusers invite chauffeurs for a selected customer', function (): voi
}
}
});
it('applies simplified driver access templates when inviting and updating chauffeur grants', function (): void {
api_test_covers('POST /superuser/subusers/invite', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'happy');
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'failure');
$session = api_fixtures()->createUserSession(['add_subusers', 'manage_subuser_grants']);
$customer = api_fixtures()->createUser(['display_name' => 'Template Target Customer']);
$phone = 72000000 + ((int)$customer['customer_number'] % 1000000);
$createdSubuserId = null;
$createdGrantId = null;
$setupToken = null;
try {
$invite = api_client()->post('/superuser/subusers/invite', [
'customer_number' => (int)$customer['customer_number'],
'name' => 'Template Driver',
'phone_country_code' => 45,
'phone' => $phone,
'permission_template_key' => 'booking_coordinator',
], $session['headers']);
$invite
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $invite->data();
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
expect($payload['subuser']['permission_template_key'] ?? null)->toBe('booking_coordinator');
expect($payload['subuser']['grant_permissions'] ?? null)
->toContain('BOOKINGS_EDIT')
->toContain('BOOKINGS_ADD')
->not->toContain('SUBUSERS_ADD');
$deactivate = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'deactivated'],
$session['headers']
);
$deactivate
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($deactivate->data()['subuser']['permission_template_key'] ?? null)->toBe('deactivated');
expect($deactivate->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($deactivate->data()['grant']['permissions'] ?? null)->toBe([]);
$invalid = api_client()->request(
'PATCH',
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
['permission_template_key' => 'unknown_template'],
$session['headers']
);
$invalid
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
} finally {
if ($setupToken !== null && $setupToken !== '') {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
if ($createdGrantId !== null) {
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
}
if ($createdSubuserId !== null) {
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
}
}
});
it('lists and summarizes chauffeurs through the user-scoped superuser route', function (): void {
$session = api_fixtures()->createUserSession(['list_subusers']);
$targetCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Alpha',
'economic_customer_name' => 'Scoped Customer Alpha',
]);
$otherCustomer = api_fixtures()->createUser([
'display_name' => 'Scoped Customer Beta',
'economic_customer_name' => 'Scoped Customer Beta',
]);
$activeSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Active Driver']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Scoped Pending Driver',
'password_plaintext' => null,
]);
$disabledSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Disabled Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Other Driver']);
$activeGrantId = api_fixtures()->grantSubuser(
(int)$activeSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['BOOKINGS_LIST']
);
$disabledGrantId = api_fixtures()->grantSubuser(
(int)$disabledSubuser['id'],
(int)$targetCustomer['customer_number'],
['ORDERS_LIST']
);
api_test_runtime()->db()->query('UPDATE `subuser_grants` SET `enabled` = 0 WHERE `id` = ' . $disabledGrantId);
api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['SELFSERVE_LIST']
);
$response = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers?page=1&limit=20&include_non_enabled=true',
$session['headers']
);
$summary = api_client()->get(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/summary',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$summary
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = is_array($response->data()) ? $response->data() : [];
$grantIds = array_map(static fn (array $row): int => (int)($row['grant_id'] ?? 0), $rows);
expect($grantIds)->toContain($activeGrantId);
expect($grantIds)->not->toContain(0);
expect($rows)->toHaveCount(3);
foreach ($rows as $row) {
expect($row['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number']);
}
expect($response->meta()['user_context']['user_id'] ?? null)->toBe((int)$targetCustomer['id']);
expect($response->meta()['subusers_summary'] ?? null)->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
expect($summary->data())->toMatchArray([
'total' => 3,
'active' => 1,
'pending_setup' => 1,
'disabled' => 1,
]);
});
it('edits only customer-matching chauffeur grants through the user-scoped route', function (): void {
$session = api_fixtures()->createUserSession(['manage_subuser_grants']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']);
$targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']);
$targetGrantId = api_fixtures()->grantSubuser(
(int)$targetSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$otherGrantId = api_fixtures()->grantSubuser(
(int)$otherSubuser['id'],
(int)$otherCustomer['customer_number'],
['BOOKINGS_LIST']
);
$update = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId,
[
'enabled' => false,
'note' => 'Scoped note',
'permissions' => ['ORDERS_LIST'],
],
$session['headers']
);
$crossCustomer = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId,
['note' => 'Should not save'],
$session['headers']
);
$update
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$crossCustomer
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
expect($update->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note');
expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']);
});
it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void {
$session = api_fixtures()->createUserSession(['add_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Other']);
$response = api_client()->post('/superuser/users/' . $targetCustomer['id'] . '/subusers/invite', [
'customer_number' => (int)$otherCustomer['customer_number'],
'name' => 'Mismatched Driver',
'phone_country_code' => 45,
'phone' => 71999999,
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Customer number does not match selected user');
});
it('resends pending user-scoped chauffeur invites and blocks accepted accounts', function (): void {
$session = api_fixtures()->createUserSession(['edit_subusers']);
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Resend Customer']);
$pendingSubuser = api_fixtures()->createSubuser([
'name' => 'Pending Resend Driver',
'password_plaintext' => null,
]);
$acceptedSubuser = api_fixtures()->createSubuser(['name' => 'Accepted Resend Driver']);
api_fixtures()->grantSubuser(
(int)$pendingSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
api_fixtures()->grantSubuser(
(int)$acceptedSubuser['id'],
(int)$targetCustomer['customer_number'],
['VEHICLES_LIST']
);
$pending = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $pendingSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$accepted = api_client()->post(
'/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $acceptedSubuser['id'] . '/invite/resend',
[],
$session['headers']
);
$pending
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$accepted
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
$token = $pending->data()['invite']['setup_token'] ?? null;
expect($token)->toBeString();
(new \objects\subusers_o())->invalidateSetupToken((string)$token);
});

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