Implement economic config round-trip test and enhance department handling

- Added a test to ensure correct round-tripping of default distribution department config value through economic config updates.
- Improved department handling by adding fallback logic to use the default economic distribution department id when a customer's department id is missing.
- Enhanced weather API routes to fetch, cache, and return detailed employee contributions per department for a given time slot.
This commit is contained in:
Jeppe Bundgaard
2026-05-13 17:37:49 +02:00
parent 30ed01e717
commit 0399cb4bb4
18 changed files with 1133 additions and 9 deletions
+9
View File
@@ -30,6 +30,7 @@ use interfaces\economic_i;
class economic implements economic_i
{
public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
public const DEFAULT_DISTRIBUTION_DEPARTMENT_ID = 1;
/**
* Configuration of the economic module
@@ -133,6 +134,14 @@ class economic implements economic_i
return $customer_number > 0 ? $customer_number : null;
}
public function getDefaultDistributionDepartmentId(): int
{
$value = $this->config->default_department_id->getVariableValue();
$department_id = (int)$value;
return $department_id > 0 ? $department_id : self::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
}
public function isDraftCustomerNumber(?int $customer_number): bool
{
$configured_customer_number = $this->getTransactionDraftCustomerNumber();
+60
View File
@@ -395,6 +395,11 @@ class redis implements redis_i
return $prefix . ':' . $dateFrom . ':' . $dateTo;
}
private function workfeedEmployeeNameCacheKey(string $employeeId): string
{
return 'workfeed_employee_name:' . rawurlencode($employeeId);
}
/**
* @inheritDoc
*/
@@ -447,6 +452,61 @@ class redis implements redis_i
return $this;
}
/**
* @inheritDoc
*/
public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self
{
$normalizedEmployeeId = trim($employeeId);
if ($normalizedEmployeeId === '') {
return $this;
}
$normalizedEmployeeName = trim($employeeName);
if ($normalizedEmployeeName === '') {
return $this;
}
$key = $this->workfeedEmployeeNameCacheKey($normalizedEmployeeId);
$this->set($key, $normalizedEmployeeName);
$this->expire($key, $ttl);
return $this;
}
/**
* @inheritDoc
*/
public function get_workfeed_employee_name(string $employeeId): string|null
{
$normalizedEmployeeId = trim($employeeId);
if ($normalizedEmployeeId === '') {
return null;
}
$value = $this->get($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId));
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/**
* @inheritDoc
*/
public function clear_workfeed_employee_name(string $employeeId): self
{
$normalizedEmployeeId = trim($employeeId);
if ($normalizedEmployeeId === '') {
return $this;
}
$this->delete($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId));
return $this;
}
/**
* @inheritDoc
*/
@@ -44,8 +44,20 @@ final class workfeed_shift_time_resolver
'clockOut',
'clockOutTime',
]);
$check_in_punch = self::firstDateTimeFromPaths($record, [
'checkIn.time',
'checkIn',
]);
$check_out_punch = self::firstDateTimeFromPaths($record, [
'checkOut.time',
'checkOut',
]);
$saved_actual_end = $actual_only_end;
if ($saved_actual_end === null && $check_in_punch !== null && $check_out_punch === null) {
$saved_actual_end = new DateTime();
}
if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
return null;
}
+243
View File
@@ -5,10 +5,12 @@ use classes\backup_store;
use classes\economic;
use classes\economic_transfer_queue;
use classes\invoice_period_flag_service;
use classes\redis;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
use classes\system_search_registry;
use classes\workfeed;
use classes\xlvask;
use classes\slack as Slack;
use classes\email as Email;
@@ -124,6 +126,12 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'PreloadDepartmentWeatherResponsesCron',
],
'WarmWorkfeedEmployeeNamesCron' => [
'interval' => 21600, // 6 hours
'last_run' => 0,
'next_run' => 0,
'function' => 'WarmWorkfeedEmployeeNamesCron',
],
'GoalsProgressAlertsCron' => [
'interval' => 60, // check every minute
'last_run' => 0,
@@ -183,6 +191,241 @@ function WarmInvoicePeriodAutomaticFlagsCron(): void
}
}
function WarmWorkfeedEmployeeNamesCron(): void
{
if (!defined('redis')) {
warn('WarmWorkfeedEmployeeNamesCron skipped: Redis is unavailable.');
return;
}
$start = microtime(true);
$ttlRaw = getenv('WORKFEED_EMPLOYEE_NAME_CACHE_TTL');
$ttl = max(
60,
(int)(
$ttlRaw !== false && trim((string)$ttlRaw) !== ''
? $ttlRaw
: 86400
)
);
try {
$employeesResponse = (new workfeed())->listEmployees();
} catch (Throwable $e) {
warn('WarmWorkfeedEmployeeNamesCron failed to list employees: ' . $e->getMessage());
return;
}
$employees = normalizeWorkfeedEmployeeWarmupCollection($employeesResponse);
if ($employees === []) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: no employees returned.\n";
return;
}
try {
$cache = new redis();
} catch (Throwable $e) {
warn('WarmWorkfeedEmployeeNamesCron failed to initialize cache: ' . $e->getMessage());
return;
}
$cachedCount = 0;
$skippedCount = 0;
foreach ($employees as $employee) {
$identity = extractWorkfeedEmployeeWarmupIdentity($employee);
$employeeIds = extractWorkfeedEmployeeWarmupIds($employee);
$employeeName = $identity['name'] ?? null;
if ($employeeIds === [] || $employeeName === null) {
$skippedCount++;
continue;
}
foreach ($employeeIds as $employeeId) {
$cache->cache_workfeed_employee_name($employeeId, $employeeName, $ttl);
$cachedCount++;
}
}
$durationMs = (int)round((microtime(true) - $start) * 1000);
echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: cached " . $cachedCount
. " employees, skipped " . $skippedCount . " in " . $durationMs . "ms.\n";
}
/**
* @return array<int,mixed>
*/
function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array
{
if (is_array($raw)) {
return array_values($raw);
}
if ($raw instanceof Traversable) {
return array_values(iterator_to_array($raw, false));
}
if (!is_object($raw)) {
return [];
}
$record = get_object_vars($raw);
foreach (['data', 'items', 'employees', 'results'] as $key) {
$nested = $record[$key] ?? null;
$normalized = normalizeWorkfeedEmployeeWarmupCollection($nested);
if ($normalized !== []) {
return $normalized;
}
}
return [$raw];
}
/**
* @return array{id:?string,name:?string}
*/
function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
{
$employeeIds = extractWorkfeedEmployeeWarmupIds($employee);
$record = is_object($employee)
? get_object_vars($employee)
: (is_array($employee) ? $employee : []);
if ($record === []) {
return [
'id' => null,
'name' => null,
];
}
$employeeId = $employeeIds[0] ?? null;
$employeeName = null;
foreach ([
'employeeName',
'employee.name',
'employee.fullName',
'employee.full_name',
'employee.displayName',
'employee.display_name',
'name',
'fullName',
'full_name',
'displayName',
'display_name',
'user.name',
'user.fullName',
'user.full_name',
'user.displayName',
'user.display_name',
] as $path) {
$employeeName = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path));
if ($employeeName !== null) {
break;
}
}
if ($employeeName === null) {
$firstName = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'firstname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'firstName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'first_name'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.firstname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.firstName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.first_name'));
$lastName = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'lastname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'lastName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'last_name'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.lastname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.lastName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.last_name'));
$parts = array_values(array_filter([$firstName, $lastName], static fn (?string $value): bool => $value !== null));
if ($parts !== []) {
$employeeName = implode(' ', $parts);
}
}
if ($employeeName !== null && strcasecmp($employeeName, 'Unknown employee') === 0) {
$employeeName = null;
}
return [
'id' => $employeeId,
'name' => $employeeName,
];
}
/**
* @return array<int,string>
*/
function extractWorkfeedEmployeeWarmupIds(mixed $employee): array
{
$record = is_object($employee)
? get_object_vars($employee)
: (is_array($employee) ? $employee : []);
if ($record === []) {
return [];
}
$employeeIds = [];
foreach ([
'employeeID',
'employeeId',
'id',
'uuid',
'employee.id',
'employee.employeeID',
'employee.employeeId',
'employee.uuid',
'employeeUUID',
'employee_uuid',
'user.id',
'userId',
] as $path) {
$employeeId = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path));
if ($employeeId === null) {
continue;
}
$employeeIds[$employeeId] = true;
}
return array_keys($employeeIds);
}
function getWarmupRecordValueByPath(array $record, string $path): mixed
{
$segments = explode('.', $path);
$value = $record;
foreach ($segments as $segment) {
if (is_array($value) && array_key_exists($segment, $value)) {
$value = $value[$segment];
continue;
}
if (is_object($value) && isset($value->{$segment})) {
$value = $value->{$segment};
continue;
}
return null;
}
return $value;
}
function normalizeWarmupTextValue(mixed $value): ?string
{
if (is_string($value) || is_numeric($value)) {
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
return null;
}
function checkUnfulfilledBookings(): void
{
// This is deactivated for now, as it is not wanted.
+23
View File
@@ -359,6 +359,29 @@ interface redis_i
*/
public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self;
/**
* Cache Workfeed employee display name by employee id
* @param string $employeeId
* @param string $employeeName
* @param int $ttl
* @return self
*/
public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self;
/**
* Get cached Workfeed employee display name by employee id
* @param string $employeeId
* @return string|null
*/
public function get_workfeed_employee_name(string $employeeId): string|null;
/**
* Clear cached Workfeed employee display name by employee id
* @param string $employeeId
* @return self
*/
public function clear_workfeed_employee_name(string $employeeId): self;
/**
* Cache a permission evaluation
* @param string $cache_key
@@ -0,0 +1,28 @@
<?php
namespace config;
use traits\module_config_variable;
class economic_default_department_id_c
{
use module_config_variable;
/**
* @throws \Exception
*/
public function __construct()
{
self::setupConfigVariable(
'economic',
'defaultDepartmentId',
'int',
false,
null,
'Fallback department id used when customers have no standard department for invoicing distribution',
'1',
false,
1
);
}
}
@@ -5,12 +5,14 @@ require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
require_once WD . '/modules/economic/config/economic_transaction_draft_customer_number_c.php';
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
use config\economic_invoice_layout_c;
use config\economic_payment_terms_c;
use config\economic_admin_fee_monthly_c;
use config\economic_admin_fee_order_c;
use config\economic_fee_product_id_c;
use config\economic_default_department_id_c;
use config\economic_transaction_draft_customer_number_c;
use traits\module_config_t;
@@ -23,6 +25,7 @@ class economic_c
public economic_admin_fee_monthly_c $admin_fee_monthly;
public economic_admin_fee_order_c $admin_fee_order;
public economic_fee_product_id_c $fee_product_id;
public economic_default_department_id_c $default_department_id;
public economic_transaction_draft_customer_number_c $transaction_draft_customer_number;
public function __construct()
@@ -34,6 +37,7 @@ class economic_c
economic_admin_fee_monthly_c::class,
economic_admin_fee_order_c::class,
economic_fee_product_id_c::class,
economic_default_department_id_c::class,
economic_transaction_draft_customer_number_c::class,
]);
$this->invoice_layout = new economic_invoice_layout_c();
@@ -41,6 +45,7 @@ class economic_c
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
$this->admin_fee_order = new economic_admin_fee_order_c();
$this->fee_product_id = new economic_fee_product_id_c();
$this->default_department_id = new economic_default_department_id_c();
$this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c();
}
}
+3 -1
View File
@@ -2204,6 +2204,8 @@ paths:
transaction_draft_customer_number:
type: integer
nullable: true
default_distribution_department_id:
type: integer
additionalProperties: false
additionalProperties: true
'400':
@@ -12738,7 +12740,7 @@ components:
type: object
properties:
module: { type: string, enum: [economic] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber, defaultDepartmentId] }
type: { type: string, enum: [string, int] }
value:
oneOf:
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\economic;
use classes\economic_transfer_queue;
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
@@ -76,6 +77,12 @@ class InvoicingPeriodRoute
return (string)($names[$customerNumber] ?? 'Unknown Customer');
}
private static function getEconomicFallbackDepartmentId(): int
{
$department_id = (new economic())->getDefaultDistributionDepartmentId();
return $department_id > 0 ? $department_id : economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
}
/**
* Slack summaries are expensive on request latency, so they are opt-in.
* Enable with query param `sendSlackSummary=1` or env `INVOICING_PERIOD_SEND_SLACK_SUMMARY=true`.
@@ -396,6 +403,7 @@ class InvoicingPeriodRoute
'page' => $page,
'limit' => $limit,
'search' => trim((string)($parameters['search'] ?? '')),
'flagTab' => trim((string)($parameters['flagTab'] ?? 'all')),
'includeRequiresAction' => self::parsePeriodBooleanOption(
$parameters['includeRequiresAction'] ?? null,
true
@@ -442,6 +450,12 @@ class InvoicingPeriodRoute
$periodView = 'all';
}
$typeCounts = self::summarizePeriodTypes($types);
if (!empty($options['flagTab'])) {
$types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']);
}
$total = count($types[$periodView] ?? []);
$limit = $options['limit'] ?? 100;
$isAllLimit = $limit === 'all';
@@ -459,7 +473,7 @@ class InvoicingPeriodRoute
}
$period['types'] = $pagedTypes;
$period['type_counts'] = self::summarizePeriodTypes($types);
$period['type_counts'] = $typeCounts;
$period['type_totals'] = self::summarizePeriodTypeTotals($types);
return [
@@ -468,6 +482,7 @@ class InvoicingPeriodRoute
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $totalPages,
'search' => (string)($options['search'] ?? ''),
'filters' => [
'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true),
@@ -481,6 +496,46 @@ class InvoicingPeriodRoute
];
}
private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array
{
if (in_array($flagTab, ['all', 'filters', ''], true)) {
return $types;
}
foreach ($types as $viewName => $entries) {
$types[$viewName] = array_values(array_filter(
is_array($entries) ? $entries : [],
static function (array $customer) use ($flagTab): bool {
$hasManual = false;
$hasAutomatic = false;
if (is_array($customer['flags'] ?? null)) {
foreach ($customer['flags'] as $flag) {
if (!empty($flag['order_id']) || !empty($flag['invoice_collection_id'])) {
continue;
}
if ($flag['is_manual'] ?? ($flag['source'] ?? '') === 'manual') {
$hasManual = true;
} else {
$hasAutomatic = true;
}
}
}
$tab = 'none';
if ($hasManual) {
$tab = 'red';
} elseif ($hasAutomatic) {
$tab = 'yellow';
}
return $tab === $flagTab;
}
));
}
return $types;
}
private static function ensurePeriodTypeKeys(array $types): array
{
foreach (self::periodTypeNames() as $typeName) {
@@ -1601,12 +1656,17 @@ class InvoicingPeriodRoute
if (self::debug) echo "Fallback 3 not applied: No recent transactions found for customer\n";
return false; // No recent transactions found
}
// 4. If no departments are found, assign the subscription to the customers default department (e.g., department ID 1).
// 4. If no departments are found, assign the subscription to customer default department
// or fallback to e-conomic default distribution department.
private static function useDefaultDepartment(array &$customer, int $subscription_price): bool
{
if (self::debug) echo "Fallback 4: Using default department\n";
$default_department_id = (new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment();
if (!empty($default_department_id)) {
$customer_default_department_id = (int)(new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment();
$default_department_id = $customer_default_department_id > 0
? $customer_default_department_id
: self::getEconomicFallbackDepartmentId();
if ($default_department_id > 0) {
if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id])) {
$customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id] = 0;
}
@@ -1696,6 +1756,15 @@ class InvoicingPeriodRoute
// Add the transaction amount to the department total
$department_totals[$department_id] += $transaction_original_price;
}
} else {
$customer_default_department_id = (int)(new users_o())->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment();
$fallback_department_id = $customer_default_department_id > 0
? $customer_default_department_id
: self::getEconomicFallbackDepartmentId();
if ($fallback_department_id > 0) {
$department_totals[$fallback_department_id] = (float)$customer['meta']['fixed_pricing']['price'];
}
}
$customer['meta']['fixed_pricing']['original_price'] = $original_price;
+1
View File
@@ -825,6 +825,7 @@ class authRoute
[
'economic' => [
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(),
],
]
);
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\redis;
use classes\response;
use classes\router;
use classes\weatherapi;
@@ -155,6 +156,42 @@ class moduleWeatherAPIRoute
'department_access_:id' => 'Access weather timeline for one or more specific departments',
]);
$this->get('/departments/weather/hours/details', function () {
global $response;
self::requirePermission('departments_weather_get');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$department_ids = self::parseDepartmentIdsFromRequest();
foreach ($department_ids as $department_id) {
self::requireDepartmentAccess((string)$department_id);
}
$slot = self::parseDepartmentWeatherHourSlotFromRequest();
$departments = self::loadDepartmentsByIds($department_ids);
$departments_with_employees = $this->loadDepartmentWeatherEmployeeHourDetailsByDepartment($departments, $slot['slotStart']);
$department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);
$total_hours = 0.0;
foreach ($departments_with_employees as $department) {
$total_hours += (float)($department['hours'] ?? 0.0);
}
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_HOUR_DETAILS_GET', 'Department weather hour details fetched');
$response->success([
'date' => $slot['date'],
'time' => $slot['time'],
'slot' => $slot['slotKey'],
'hours' => round($total_hours, 2),
'departments' => $departments_with_employees,
], 200);
}, [
'departments_weather_get' => 'Get department weather employee hour details for one hour slot',
'department_access_:id' => 'Access weather hour details for one or more specific departments',
]);
$this->get('/departments/weather/targets', function () {
global $response;
self::requirePermission('departments_weather_targets_get');
@@ -819,6 +856,45 @@ class moduleWeatherAPIRoute
];
}
private function parseDepartmentWeatherHourSlotFromRequest(): array
{
global $response;
self::requireParameters(['date', 'time']);
$date_value = self::getParameter('date');
$time_value = self::getParameter('time');
if (!is_string($date_value) || !is_string($time_value)) {
$response->error('Invalid type. Expected: string for date/time', 400);
}
$date = trim((string)$date_value);
$time = trim((string)$time_value);
self::requireDateFormat($date, self::FORMAT_DATE());
if (!preg_match('/^\d{2}:\d{2}(?::\d{2})?$/', $time)) {
$response->error('Invalid time format. Expected HH:MM', 400);
}
$time_parts = explode(':', $time);
$hour = (int)($time_parts[0] ?? -1);
$minute = (int)($time_parts[1] ?? -1);
if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) {
$response->error('Invalid time value', 400);
}
$normalized_time = sprintf('%02d:%02d', $hour, $minute);
$slot_start = new DateTime($date . ' ' . $normalized_time . ':00');
$slot_end = clone $slot_start;
$slot_end->add(new DateInterval('PT1H'));
return [
'date' => $date,
'time' => $normalized_time,
'slotKey' => $slot_start->format('Y-m-d H:00'),
'slotStart' => $slot_start,
'slotEnd' => $slot_end,
];
}
private function loadDepartmentsByIds(array $department_ids): array
{
$departments = [];
@@ -1688,6 +1764,329 @@ class moduleWeatherAPIRoute
return round($hours, 2);
}
/**
* @throws Exception
*/
private function loadDepartmentWeatherEmployeeHourDetailsByDepartment(array $departments, DateTime $slot_start): array
{
try {
$workfeed = new workfeed();
$employeeNameCache = null;
if (defined('redis')) {
try {
$employeeNameCache = new redis();
} catch (Exception) {
$employeeNameCache = null;
}
}
$workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed);
if ($workfeed_department_ids_by_department === []) {
return [];
}
$slot_end = clone $slot_start;
$slot_end->add(new DateInterval('PT1H'));
$query_start = clone $slot_start;
$query_start->sub(new DateInterval('P1D'));
$shifts_response = $workfeed->listShifts([
'startFrom' => $query_start->format(DateTime::ATOM),
'startTo' => $slot_end->format(DateTime::ATOM),
]);
$shifts = self::normalizeWorkfeedCollection($shifts_response);
if ($shifts === []) {
return [];
}
$occurred_until = new DateTime();
$resolved_employee_names_by_id = [];
$details = [];
foreach ($departments as $department) {
$department_id = (int)($department->id ?? 0);
if ($department_id < 1) {
continue;
}
$workfeed_department_ids = $workfeed_department_ids_by_department[$department_id] ?? null;
if ($workfeed_department_ids === null) {
continue;
}
$employees = self::calculateWorkfeedEmployeeHoursForHourByEmployee(
$shifts,
$workfeed_department_ids,
$slot_start,
$occurred_until
);
$employees = $this->resolveMissingWorkfeedEmployeeNames(
$employees,
$employeeNameCache,
$resolved_employee_names_by_id
);
if ($employees === []) {
continue;
}
$department_hours = 0.0;
foreach ($employees as $employee) {
$department_hours += (float)($employee['hours'] ?? 0.0);
}
$details[] = [
'department_id' => $department_id,
'department_name' => trim((string)($department->name ?? '')) ?: ('Department ' . $department_id),
'hours' => round($department_hours, 2),
'employees' => $employees,
];
}
usort($details, static function (array $left, array $right): int {
return strcasecmp((string)($left['department_name'] ?? ''), (string)($right['department_name'] ?? ''));
});
return $details;
} catch (Exception) {
return [];
}
}
private function calculateWorkfeedEmployeeHoursForHourByEmployee(
array $shifts,
string|array $workfeed_department_ids,
DateTime $slot_start,
?DateTime $occurred_until = null
): array
{
$department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
$department_id_lookup = [];
foreach ($department_id_values as $department_id_value) {
$normalized = trim((string)$department_id_value);
if ($normalized !== '') {
$department_id_lookup[$normalized] = true;
}
}
if ($department_id_lookup === []) {
return [];
}
$slot_end = clone $slot_start;
$slot_end->add(new DateInterval('PT1H'));
$slot_start_ts = $slot_start->getTimestamp();
$slot_end_ts = $slot_end->getTimestamp();
$occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp();
$hours_by_employee_key = [];
foreach ($shifts as $shift) {
$shift_department_id = self::extractWorkfeedDepartmentId($shift);
if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) {
continue;
}
$timing = workfeed_shift_time_resolver::resolveShiftTiming($shift);
if ($timing === null) {
continue;
}
$shift_start_ts = $timing['actualStart']->getTimestamp();
$shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts);
if ($shift_end_ts <= $shift_start_ts) {
continue;
}
$overlap_start = max($slot_start_ts, $shift_start_ts);
$overlap_end = min($slot_end_ts, $shift_end_ts);
if ($overlap_end <= $overlap_start) {
continue;
}
$employee_identity = self::extractWorkfeedEmployeeIdentity($shift);
$employee_id = $employee_identity['id'];
$employee_name = self::normalizeShiftTextValue($employee_identity['name'] ?? null) ?? 'Unknown employee';
$employee_key = $employee_id ?? ('name:' . strtolower($employee_name));
if (!isset($hours_by_employee_key[$employee_key])) {
$hours_by_employee_key[$employee_key] = [
'employee_id' => $employee_id,
'employee_name' => $employee_name,
'hours' => 0.0,
];
}
$hours_by_employee_key[$employee_key]['hours'] += ($overlap_end - $overlap_start) / 3600;
}
foreach ($hours_by_employee_key as &$employee) {
$employee['hours'] = round((float)$employee['hours'], 2);
}
unset($employee);
usort($hours_by_employee_key, static function (array $left, array $right): int {
return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? ''));
});
return array_values(array_filter($hours_by_employee_key, static function (array $employee): bool {
return (float)($employee['hours'] ?? 0.0) > 0;
}));
}
private function resolveMissingWorkfeedEmployeeNames(array $employees, ?redis $employee_name_cache, array &$resolved_names_by_employee_id): array
{
foreach ($employees as &$employee) {
$employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null);
if ($employee_id === null) {
continue;
}
$employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null);
if (!self::isMissingEmployeeDisplayName($employee_name, $employee_id)) {
continue;
}
if (!array_key_exists($employee_id, $resolved_names_by_employee_id)) {
$resolved_names_by_employee_id[$employee_id] = $this->fetchCachedWorkfeedEmployeeDisplayName(
$employee_name_cache,
$employee_id
);
}
$resolved_name = $resolved_names_by_employee_id[$employee_id] ?? null;
if ($resolved_name !== null) {
$employee['employee_name'] = $resolved_name;
}
}
unset($employee);
usort($employees, static function (array $left, array $right): int {
return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? ''));
});
return $employees;
}
private function fetchCachedWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id): ?string
{
if ($employee_name_cache === null) {
return null;
}
try {
$employee_name = $employee_name_cache->get_workfeed_employee_name($employee_id);
} catch (Exception) {
return null;
}
return self::isMissingEmployeeDisplayName($employee_name, $employee_id) ? null : $employee_name;
}
private function isMissingEmployeeDisplayName(?string $employee_name, ?string $employee_id = null): bool
{
if ($employee_name === null) {
return true;
}
if ($employee_id !== null && strcasecmp($employee_name, 'Employee ' . $employee_id) === 0) {
return true;
}
return strcasecmp($employee_name, 'Unknown employee') === 0;
}
private function extractWorkfeedEmployeeIdentity(mixed $shift): array
{
$record = self::normalizeWorkfeedRecord($shift);
$employee_id = null;
foreach ([
'employeeID',
'employeeId',
'employee_id',
'employee.id',
'employee.employeeID',
'employee.employeeId',
'employee.employee_id',
'employee.uuid',
'employeeUUID',
'employee_uuid',
'user.id',
'userId',
] as $path) {
$value = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path));
if ($value !== null) {
$employee_id = $value;
break;
}
}
$employee_name = null;
foreach ([
'employeeName',
'employee.name',
'employee.fullName',
'employee.full_name',
'employee.displayName',
'employee.display_name',
'name',
'fullName',
'full_name',
'displayName',
'display_name',
'user.name',
'user.fullName',
'user.full_name',
'user.displayName',
'user.display_name',
] as $path) {
$value = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path));
if ($value !== null) {
$employee_name = $value;
break;
}
}
if ($employee_name === null) {
$first_name = self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.firstname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.firstName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.first_name'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'firstname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'firstName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'first_name'));
$last_name = self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.lastname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.lastName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.last_name'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'lastname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'lastName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'last_name'));
$combined_name = trim((string)($first_name ?? '') . ' ' . (string)($last_name ?? ''));
if ($combined_name !== '') {
$employee_name = $combined_name;
}
}
if ($employee_name === null) {
$employee_name = 'Unknown employee';
}
return [
'id' => $employee_id,
'name' => $employee_name,
];
}
private function normalizeShiftTextValue(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
}
$normalized = trim((string)$value);
if ($normalized === '') {
return null;
}
return $normalized;
}
/**
* @throws Exception
*/
+8 -2
View File
@@ -77,6 +77,7 @@ it('returns the cached auth session payload for a valid token', function (): voi
]
);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '445566', 'int');
api_fixtures()->setModuleConfig('economic', 'defaultDepartmentId', '75', 'int');
$response = api_client()->get('/auth/session', $session['headers']);
@@ -92,13 +93,16 @@ it('returns the cached auth session payload for a valid token', function (): voi
->and($response->data()['permissions'])
->toContain('list_departments')
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
->toBe(445566);
->toBe(445566)
->and($response->data()['runtime_config']['economic']['default_distribution_department_id'] ?? null)
->toBe(75);
});
it('includes economic runtime config for uncached auth sessions', function (): void {
api_test_covers('GET /auth/session', 'happy');
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '556677', 'int');
api_fixtures()->setModuleConfig('economic', 'defaultDepartmentId', '65', 'int');
$session = api_fixtures()->createUserSession(['list_departments'], [
'display_name' => 'Fresh Session User',
]);
@@ -114,7 +118,9 @@ it('includes economic runtime config for uncached auth sessions', function (): v
->toBeArray()
->toHaveKey('customer_number', $session['user']['customer_number'])
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
->toBe(556677);
->toBe(556677)
->and($response->data()['runtime_config']['economic']['default_distribution_department_id'] ?? null)
->toBe(65);
});
it('rejects invalid auth session tokens', function (): void {
@@ -83,6 +83,33 @@ it('round-trips the draft customer config value through economic config updates'
expect($clearedEntry['value'])->toBeNull();
});
it('round-trips the default distribution department config value through economic config updates', function (): void {
api_test_covers('POST /economic/config', 'happy');
api_fixtures()->preserveModuleConfig('economic', 'defaultDepartmentId');
$session = api_fixtures()->createUserSession(['economic_config']);
api_client()->post('/economic/config', [
'variable' => 'defaultDepartmentId',
'value' => 75,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$configuredResponse = api_client()->get('/economic/config?variable=defaultDepartmentId', $session['headers']);
$configuredResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$configuredEntry = economic_draft_customer_find_config_entry((array)$configuredResponse->data(), 'defaultDepartmentId');
expect($configuredEntry)
->not->toBeNull()
->and($configuredEntry['type'] ?? null)->toBe('int')
->and($configuredEntry['value'] ?? null)->toBe(75);
});
it('rejects order draft exports for the configured draft customer', function (): void {
api_test_covers('POST /economic/invoice/draft/export', 'failure');
@@ -105,3 +105,16 @@ it('uses batched period transactions and keyed customer maps in the main period
->and($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400)')
->and($content)->not->toContain('getOrdersWithPossibleDuplicates($dateFrom, $dateTo)');
});
it('falls back to configured e-conomic default department for missing customer default department in distributions', function (): void {
$content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)
->toContain('private static function getEconomicFallbackDepartmentId(): int')
->and($content)->toContain('(new economic())->getDefaultDistributionDepartmentId()')
->and($content)->toContain(': self::getEconomicFallbackDepartmentId();')
->and($content)->toContain("\$department_totals[\$fallback_department_id] = (float)\$customer['meta']['fixed_pricing']['price'];");
});
@@ -21,3 +21,28 @@ it('registers and implements cron preloading for department weather cache', func
expect($routeContent)->toContain('withCachedDepartmentWeatherTimeline');
expect($routeContent)->toContain('getDepartmentWeatherCacheKey');
});
it('registers and implements cron warming for workfeed employee name cache', function (): void {
$cronContent = file_get_contents(app_path('cron/Cron.php'));
$routeContent = file_get_contents(app_path('routes/moduleWeatherAPIRoute.php'));
expect($cronContent)->not->toBeFalse();
expect($routeContent)->not->toBeFalse();
expect($cronContent)->toContain('WarmWorkfeedEmployeeNamesCron');
expect($cronContent)->toContain("'function' => 'WarmWorkfeedEmployeeNamesCron'");
expect($cronContent)->toContain("'interval' => 21600");
expect($cronContent)->toContain('WORKFEED_EMPLOYEE_NAME_CACHE_TTL');
expect($cronContent)->toContain('cache_workfeed_employee_name');
expect($cronContent)->toContain('normalizeWorkfeedEmployeeWarmupCollection');
expect($cronContent)->toContain('extractWorkfeedEmployeeWarmupIdentity');
expect($cronContent)->toContain('extractWorkfeedEmployeeWarmupIds');
expect($cronContent)->toContain('foreach ($employeeIds as $employeeId)');
expect($cronContent)->toContain('getWarmupRecordValueByPath($record, \'firstname\')');
expect($cronContent)->toContain('getWarmupRecordValueByPath($record, \'lastname\')');
expect($cronContent)->toContain('getWarmupRecordValueByPath($record, \'employee.firstname\')');
expect($cronContent)->toContain('getWarmupRecordValueByPath($record, \'employee.lastname\')');
expect($routeContent)->toContain('fetchCachedWorkfeedEmployeeDisplayName');
expect($routeContent)->toContain('get_workfeed_employee_name');
});
@@ -11,6 +11,17 @@ it('registers department weather target routes with explicit read and manage per
expect($content)->toContain("'department_access_:id'");
});
it('registers department weather hour details route with weather-read and department access checks', function (): void {
$routeFile = app_path('routes/moduleWeatherAPIRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/departments/weather/hours/details');
expect($content)->toContain("requirePermission('departments_weather_get')");
expect($content)->toContain('parseDepartmentWeatherHourSlotFromRequest');
expect($content)->toContain('loadDepartmentWeatherEmployeeHourDetailsByDepartment');
});
it('persists department weather targets using canonical department variable keys', function (): void {
$routeFile = app_path('routes/moduleWeatherAPIRoute.php');
$content = file_get_contents($routeFile);
@@ -2,6 +2,8 @@
app_require('routes/moduleWeatherAPIRoute.php');
use classes\workfeed;
use classes\redis;
use routes\moduleWeatherAPIRoute;
function weather_route_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
@@ -52,6 +54,173 @@ it('calculates workfeed employee hours for the hour slot based on overlap', func
expect($hours)->toBe(1.5);
});
it('calculates weather hour contributions grouped per employee for a slot', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-24T13:00:00+00:00');
$shifts = [
(object)[
'departmentID' => 'dep_1',
'employeeID' => 'emp_1',
'employeeName' => 'Alice',
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
'end' => '2026-03-24T14:00:00+00:00',
],
(object)[
'departmentID' => 'dep_1',
'employeeID' => 'emp_1',
'employeeName' => 'Alice',
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
'checkOut' => (object)['time' => '2026-03-24T13:30:00+00:00'],
'end' => '2026-03-24T13:30:00+00:00',
],
(object)[
'departmentID' => 'dep_1',
'employeeID' => 'emp_2',
'employeeName' => 'Bob',
'checkIn' => (object)['time' => '2026-03-24T13:15:00+00:00'],
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
'end' => '2026-03-24T14:00:00+00:00',
],
(object)[
'departmentID' => 'dep_other',
'employeeID' => 'emp_3',
'employeeName' => 'Ignored',
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
'end' => '2026-03-24T14:00:00+00:00',
],
];
$details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]);
expect($details)->toBe([
[
'employee_id' => 'emp_1',
'employee_name' => 'Alice',
'hours' => 1.5,
],
[
'employee_id' => 'emp_2',
'employee_name' => 'Bob',
'hours' => 0.75,
],
]);
});
it('calculates weather hour contributions for canonical nested workfeed employee schema', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-24T13:00:00+00:00');
$shifts = [
(object)[
'departmentID' => 'dep_1',
'employee' => (object)[
'id' => '005FnnP0fHohybM1f3tx',
'firstname' => 'Michael',
'lastname' => 'Stenbæk Stampe',
],
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'],
'end' => '2026-03-24T14:00:00+00:00',
],
];
$details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]);
expect($details)->toBe([
[
'employee_id' => '005FnnP0fHohybM1f3tx',
'employee_name' => 'Michael Stenbæk Stampe',
'hours' => 1.0,
],
]);
});
it('extracts weather employee identity from supported shift payload shapes', function (): void {
$route = new moduleWeatherAPIRoute();
$fromTopLevel = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
(object)[
'employeeID' => 'emp_1',
'employeeName' => 'Alice',
],
]);
$fromNested = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
(object)[
'employee' => (object)[
'id' => 'emp_2',
'firstName' => 'Bob',
'lastName' => 'Builder',
],
],
]);
$fromCanonicalNestedSchema = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
(object)[
'employee' => (object)[
'id' => 'emp_3',
'firstname' => 'Charlie',
'lastname' => 'Day',
],
],
]);
expect($fromTopLevel)->toBe([
'id' => 'emp_1',
'name' => 'Alice',
]);
expect($fromNested)->toBe([
'id' => 'emp_2',
'name' => 'Bob Builder',
]);
expect($fromCanonicalNestedSchema)->toBe([
'id' => 'emp_3',
'name' => 'Charlie Day',
]);
});
it('uses an unknown employee label when no employee name is available', function (): void {
$route = new moduleWeatherAPIRoute();
$identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [
(object)[
'employeeID' => 'emp_99',
],
]);
expect($identity)->toBe([
'id' => 'emp_99',
'name' => 'Unknown employee',
]);
});
it('resolves employee display name from cache when shift payload lacks a name', function (): void {
$route = new moduleWeatherAPIRoute();
$cache = new class([
'emp_42' => 'Jane Doe',
]) extends redis {
private array $namesByEmployeeId;
public function __construct(array $namesByEmployeeId)
{
$this->namesByEmployeeId = $namesByEmployeeId;
}
public function get_workfeed_employee_name(string $employeeId): string|null
{
return $this->namesByEmployeeId[$employeeId] ?? null;
}
};
$resolved_name = weather_route_invoke_private($route, 'fetchCachedWorkfeedEmployeeDisplayName', [$cache, 'emp_42']);
expect($resolved_name)->toBe('Jane Doe');
});
it('extracts department id from supported workfeed shift shapes', function (): void {
$route = new moduleWeatherAPIRoute();
@@ -133,6 +302,25 @@ it('does not count shifts without punches when checkIn/checkOut are null', funct
expect($hours)->toBe(0.0);
});
it('counts checked-in shifts without checkOut up to occurredUntil', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-24T13:00:00+00:00');
$occurredUntil = new DateTime('2026-03-24T13:40:00+00:00');
$shifts = [
(object)[
'departmentID' => 'dep_1',
'checkIn' => (object)['time' => '2026-03-24T13:10:00+00:00'],
'checkOut' => null,
'end' => '2026-03-24T16:00:00+00:00',
],
];
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot, $occurredUntil]);
expect($hours)->toBe(0.5);
});
it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-23T18:00:00+00:00');
@@ -220,7 +408,8 @@ it('caps current-slot hours to elapsed minutes and zeroes future slots', functio
$shifts = [
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-24T13:00:00+00:00',
'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'],
'checkOut' => null,
'end' => '2026-03-24T15:00:00+00:00',
],
];