Compare commits

...
Author SHA1 Message Date
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 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
12 changed files with 624 additions and 15 deletions
+57
View File
@@ -12757,6 +12757,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -21566,6 +21593,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
@@ -1246,19 +1246,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));
@@ -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
@@ -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')
+57
View File
@@ -12768,6 +12768,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -21577,6 +21604,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
@@ -13,6 +13,7 @@ use DateTimeZone;
use Exception;
use objects\department_daily_report_complaints_o;
use objects\department_daily_reports_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
use objects\users_o;
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
{
use route_t;
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
public function run(): void
{
$this->get('/departments/daily-reports', function () {
@@ -850,7 +853,8 @@ class departmentDailyReportsRoute
'overview' => $this->buildDailyReportOverview(
[$department_id],
(string)self::getParameter('date'),
$date_to
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
),
]);
},
@@ -895,7 +899,8 @@ class departmentDailyReportsRoute
$this->buildDailyReportOverview(
$department_ids,
(string)self::getParameter('date'),
$date_to
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
)
);
},
@@ -906,6 +911,73 @@ class departmentDailyReportsRoute
]
);
$this->put('/departments/daily-reports/product-targets', function () {
global $response;
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
'department_id',
'product_id',
'target_percentage',
]);
$department_id = (int)self::getParameter('department_id');
if ($department_id <= 0) {
$response->error('Parameter department_id must be a positive integer', 400);
return;
}
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
self::requireDepartmentAccess($department_id);
$product_id = (int)self::getParameter('product_id');
if (!$this->isDailyReportProductId($product_id)) {
$response->error('Invalid daily report product_id', 400);
return;
}
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
if (!$parsed_target['valid']) {
$response->error($parsed_target['message'], 400);
return;
}
$target_percentage = $parsed_target['value'];
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$target_key = $this->dailyReportProductTargetVariableKey($product_id);
if ($target_percentage === null) {
$this->clearDailyReportProductTarget($department_variables, $target_key);
} else {
$department_variables->set($target_key, number_format($target_percentage, 1, '.', ''));
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'Successfully updated department daily report product target');
$response->success([
'department_id' => $department_id,
'product_id' => $product_id,
'target_percentage' => $target_percentage,
]);
},
[
self::SET_PRODUCT_TARGET_PERMISSION => 'Set department daily report product target percentages',
'department_access_:department_id' => 'Access the department'
]
);
$this->get('/departments/daily-reports/product-count', function () {
// Require the user to be logged in
global $response;
@@ -1369,7 +1441,7 @@ class departmentDailyReportsRoute
* }
* @throws Exception
*/
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to): array
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to, bool $include_product_targets = false): array
{
$repository = $this->dailyReportRepository();
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
@@ -1389,6 +1461,11 @@ class departmentDailyReportsRoute
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to);
$product_target_lookup = [];
if ($include_product_targets && count($department_ids) === 1) {
$product_target_lookup = $this->getDailyReportProductTargetsForDepartment((int)$department_ids[0], $product_definitions);
}
return $this->assembleDailyReportOverview(
$department_ids,
$date,
@@ -1399,7 +1476,8 @@ class departmentDailyReportsRoute
$product_summary_lookup,
$complaints_metric,
$night_wash_metric,
$overtime_metric
$overtime_metric,
$product_target_lookup
);
}
@@ -1412,6 +1490,7 @@ class departmentDailyReportsRoute
* @param array<string,mixed> $complaints_metric
* @param array<string,mixed> $night_wash_metric
* @param array<string,mixed> $overtime_metric
* @param array<int,float> $product_target_lookup
* @return array{
* department_ids:array<int>,
* date:string,
@@ -1430,7 +1509,8 @@ class departmentDailyReportsRoute
array $product_summary_lookup,
array $complaints_metric,
array $night_wash_metric,
array $overtime_metric
array $overtime_metric,
array $product_target_lookup = []
): array {
$products = [];
foreach ($product_definitions as $definition) {
@@ -1448,6 +1528,12 @@ class departmentDailyReportsRoute
'state' => 'ready',
'value' => (int)($product_summary['quantity'] ?? 0),
'out_of' => (int)($product_summary['out_of'] ?? 0),
'target_percentage' => array_key_exists($product_id, $product_target_lookup)
? (float)$product_target_lookup[$product_id]
: null,
'target_department_id' => array_key_exists($product_id, $product_target_lookup)
? (int)$department_ids[0]
: null,
];
}
@@ -1506,6 +1592,87 @@ class departmentDailyReportsRoute
return array_values($normalized);
}
private function isDailyReportProductId(int $product_id): bool
{
return in_array(
$product_id,
array_map(static fn(array $definition): int => (int)$definition['product_id'], $this->getDailyReportProductDefinitions()),
true
);
}
/**
* @return array{valid:bool,value:?float,message:string}
*/
private function parseDailyReportProductTargetPercentage(mixed $target_percentage): array
{
if ($target_percentage === null) {
return ['valid' => true, 'value' => null, 'message' => ''];
}
if (is_string($target_percentage)) {
$target_percentage = trim($target_percentage);
if ($target_percentage === '') {
return ['valid' => true, 'value' => null, 'message' => ''];
}
}
if (!is_int($target_percentage) && !is_float($target_percentage) && !(is_string($target_percentage) && is_numeric($target_percentage))) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be numeric, null, or empty'];
}
$target_percentage = round((float)$target_percentage, 1);
if ($target_percentage < 0.0 || $target_percentage > 100.0) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be between 0 and 100'];
}
return ['valid' => true, 'value' => $target_percentage, 'message' => ''];
}
/**
* @param array<int,array{product_id:int,slug:string,title:string}> $product_definitions
* @return array<int,float>
* @throws Exception
*/
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
{
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$targets = [];
foreach ($product_definitions as $definition) {
$product_id = (int)$definition['product_id'];
$stored_target = $department_variables->getVariable($this->dailyReportProductTargetVariableKey($product_id));
if ($stored_target === null || $stored_target === '' || !is_numeric($stored_target)) {
continue;
}
$targets[$product_id] = round((float)$stored_target, 1);
}
return $targets;
}
protected function clearDailyReportProductTarget(department_variables_o $department_variables, string $target_key): void
{
$existing_targets = $department_variables->getFieldsWhere([
'department_id' => $department_variables->department_id,
'variable' => $target_key,
], ['id']);
if (!$existing_targets) {
return;
}
department_variables_o::delete_object('department_variables', (int)$existing_targets[0]['id']);
$department_variables->objectChanged();
}
private function dailyReportProductTargetVariableKey(int $product_id): string
{
return 'daily_report_product_target_percentage_' . $product_id;
}
/**
* @return array<int,array{product_id:int,slug:string,title:string}>
*/
@@ -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',
@@ -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');
});
@@ -31,10 +31,15 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
$content = department_daily_reports_openapi_content_or_skip();
expect($content)->toContain('/departments/daily-reports/overview:');
expect($content)->toContain('/departments/daily-reports/product-targets:');
expect($content)->toContain('/superuser/departments/{id}/overview:');
expect($content)->toContain('operationId: getDailyReportOverview');
expect($content)->toContain('operationId: setDailyReportProductTarget');
expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
expect($content)->toContain('DepartmentDailyReportProductTargetRequest:');
expect($content)->toContain('set_department_daily_report_product_targets');
expect($content)->toContain('target_percentage');
expect($content)->toContain('SuperuserDepartmentOverviewResponse:');
expect($content)->toContain('DepartmentDailyReportMetric:');
expect($content)->toContain('DepartmentDailyReportProductTile:');
@@ -123,6 +123,7 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
public object $complaints_repository;
public array $opening_hours = [];
public array $departments = [];
public array $product_targets_by_department = [];
public array $workfeed_departments = [];
public array $workfeed_shifts = [];
public department_outside_hours_statistics_service $outside_hours_service;
@@ -161,6 +162,11 @@ final class DepartmentDailyReportsOverviewRouteDouble extends departmentDailyRep
{
return $this->outside_hours_service;
}
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
{
return $this->product_targets_by_department[$department_id] ?? [];
}
}
function fake_daily_report_department(int $id, string $name, array $variables = []): object
@@ -250,6 +256,8 @@ it('builds the overview payload from batched repository data with deterministic
expect($overview['products'][0]['slug'])->toBe('spot-free-lastbil');
expect($overview['products'][0]['title'])->toBe('Spot Free (Lastbil)');
expect($overview['products'][0]['value'])->toBe(3);
expect($overview['products'][0]['target_percentage'])->toBeNull();
expect($overview['products'][0]['target_department_id'])->toBeNull();
expect($overview['products'][1]['title'])->toBe('Fælg flex pr. enhed');
expect($overview['products'][1]['value'])->toBe(2);
expect(array_column($overview['products'], 'title'))->toBe([
@@ -262,6 +270,38 @@ it('builds the overview payload from batched repository data with deterministic
]);
});
it('adds product targets to single department overview payloads when requested', function (): void {
$repository = new FakeDailyReportRepository();
$repository->product_overview = [
24 => ['product_id' => 24, 'quantity' => 3, 'out_of' => 14],
25 => ['product_id' => 25, 'quantity' => 2, 'out_of' => 14],
];
$route = new DepartmentDailyReportsOverviewRouteDouble();
$route->repository = $repository;
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
$route->product_targets_by_department = [
7 => [
24 => 75.5,
25 => 0.0,
],
];
$overview = department_daily_reports_route_invoke_private($route, 'buildDailyReportOverview', [[7], '2026-03-23', '2026-03-23', true]);
$products_by_id = [];
foreach ($overview['products'] as $product) {
$products_by_id[$product['product_id']] = $product;
}
expect($products_by_id[24]['target_percentage'])->toBe(75.5);
expect($products_by_id[24]['target_department_id'])->toBe(7);
expect($products_by_id[25]['target_percentage'])->toBe(0.0);
expect($products_by_id[25]['target_department_id'])->toBe(7);
expect($products_by_id[27]['target_percentage'])->toBeNull();
expect($products_by_id[27]['target_department_id'])->toBeNull();
});
it('marks overtime unavailable when not every selected department can be mapped to workfeed', function (): void {
$repository = new FakeDailyReportRepository();
@@ -342,8 +382,10 @@ it('wires the overview route to batched repository methods and overview path', f
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
expect($routeContent)->toContain('/departments/daily-reports/overview');
expect($routeContent)->toContain('/departments/daily-reports/product-targets');
expect($routeContent)->toContain('/superuser/departments/{id}/overview');
expect($routeContent)->toContain('superuser_fetch_department');
expect($routeContent)->toContain('set_department_daily_report_product_targets');
expect($routeContent)->toContain('/departments/daily-reports/complaints');
expect($routeContent)->toContain('outsideHoursStatisticsService');
expect($routeContent)->toContain('dailyReportComplaintsRepository');
@@ -3,6 +3,20 @@
use helpers\xlvask_usage_log;
use objects\xlvask_usage_logs_o;
it('serializes empty ignore metadata as SQL null values for new usage logs', function (): void {
$log = new xlvask_usage_log();
$data = $log->toArray();
expect($data)
->toHaveKey('ignored_at')
->toHaveKey('ignored_by')
->toHaveKey('ignored_reason')
->and($data['ignored_at'])->toBeNull()
->and($data['ignored_by'])->toBeNull()
->and($data['ignored_reason'])->toBeNull()
->and($data['Updated'])->toBe('');
});
it('accepts persisted ignore metadata from xlvask usage log rows', function (): void {
$log = new xlvask_usage_log();
@@ -57,3 +71,21 @@ it('calculates XL Vask amount summaries without hydrating order item previews',
'primary_product_name' => 'Stor bil',
]);
});
it('formats date-only XL Vask usage import start dates for the upstream API', function (): void {
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'formatImportDateFrom');
expect($method->invoke(null, '2026-03-01'))->toBe('2026-03-01T00:00:00.000');
});
it('filters fetched XL Vask usage logs inclusively to the requested import end date', function (): void {
$keep = new xlvask_usage_log(['StartTime' => '2026-03-31T23:59:59.000']);
$drop = new xlvask_usage_log(['StartTime' => '2026-04-01T00:00:00.000']);
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'filterUsageLogsUntil');
$result = $method->invoke(null, [$keep, $drop], '2026-03-31');
expect($result)
->toHaveCount(1)
->and($result[0])->toBe($keep);
});
@@ -43,3 +43,22 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']")
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
});
it('scopes manual XL Vask usage import and automation to optional period dates', function (): void {
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($route)
->not->toBeFalse()
->and($automation)->not->toBeFalse();
$route = (string)$route;
$automation = (string)$automation;
expect($route)
->toContain("getParameter('dateFrom')")
->toContain("getParameter('dateTo')")
->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)')
->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
});