diff --git a/openapi.yaml b/openapi.yaml index 651a693d..21f601e3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2204,6 +2204,8 @@ paths: transaction_draft_customer_number: type: integer nullable: true + default_distribution_department_id: + type: integer additionalProperties: false additionalProperties: true '400': @@ -12536,7 +12538,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: diff --git a/services/nginx/app/classes/economic.php b/services/nginx/app/classes/economic.php index 314ad730..e506816e 100644 --- a/services/nginx/app/classes/economic.php +++ b/services/nginx/app/classes/economic.php @@ -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(); diff --git a/services/nginx/app/classes/redis.php b/services/nginx/app/classes/redis.php index 29e7f601..76c0c96a 100644 --- a/services/nginx/app/classes/redis.php +++ b/services/nginx/app/classes/redis.php @@ -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 */ diff --git a/services/nginx/app/classes/workfeed_shift_time_resolver.php b/services/nginx/app/classes/workfeed_shift_time_resolver.php index ced27cc2..821d617d 100644 --- a/services/nginx/app/classes/workfeed_shift_time_resolver.php +++ b/services/nginx/app/classes/workfeed_shift_time_resolver.php @@ -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; } diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index db52c3a9..2bd0f282 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -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 + */ +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 + */ +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. diff --git a/services/nginx/app/interfaces/redis_i.php b/services/nginx/app/interfaces/redis_i.php index a431312d..152bba20 100644 --- a/services/nginx/app/interfaces/redis_i.php +++ b/services/nginx/app/interfaces/redis_i.php @@ -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 diff --git a/services/nginx/app/modules/economic/config/economic_default_department_id_c.php b/services/nginx/app/modules/economic/config/economic_default_department_id_c.php new file mode 100644 index 00000000..994537e4 --- /dev/null +++ b/services/nginx/app/modules/economic/config/economic_default_department_id_c.php @@ -0,0 +1,28 @@ +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(); } } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 9054fd24..402f8458 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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: diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 0995f2ea..e9fa78fd 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -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; diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index ec534bd0..f8d0ff6c 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -825,6 +825,7 @@ class authRoute [ 'economic' => [ 'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(), + 'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(), ], ] ); diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index e61a5cd9..d0465786 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -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 */ diff --git a/services/nginx/app/tests/Api/AuthApiTest.php b/services/nginx/app/tests/Api/AuthApiTest.php index dd131a13..24d76508 100644 --- a/services/nginx/app/tests/Api/AuthApiTest.php +++ b/services/nginx/app/tests/Api/AuthApiTest.php @@ -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 { diff --git a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php index d23bcaff..b5bfd3ab 100644 --- a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php +++ b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php @@ -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'); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php index f707b548..62becc2a 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php @@ -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'];"); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php index d8645dce..560ae8a1 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php @@ -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'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php index 59332422..811e9372 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php @@ -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); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php index 42d9052e..d17f4991 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -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', ], ];