diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php index 6f88dbed..162d51fa 100644 --- a/services/nginx/app/classes/economic_v2_distribution_service.php +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -892,6 +892,7 @@ class economic_v2_distribution_service $weight_total = array_sum($eligible_weights); if (abs($weight_total) <= self::EPSILON) { + $fallback_department_id = $this->getFallbackDistributionDepartmentId(); $warnings[] = 'Booked department 75 ' . $source_category . ' amount for customer ' @@ -900,11 +901,15 @@ class economic_v2_distribution_service . $month_key . ' on invoice(s) ' . $invoice_ids - . ' has no redistribution basis and remains undistributed.'; + . ' has no redistribution basis and was assigned to fallback department ' + . $fallback_department_id + . '.'; return [ - 'department_distribution' => [], - 'undistributed_net_amount' => $booked_amount, + 'department_distribution' => [ + $fallback_department_id => $booked_amount, + ], + 'undistributed_net_amount' => 0.0, ]; } @@ -1061,6 +1066,9 @@ class economic_v2_distribution_service } elseif (!$this->isOrderEligible($order)) { continue; } + $distribution_department_id = $system_order_fallback + ? $this->getFallbackDistributionDepartmentId() + : $department_id; $fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at); if ($fixed_version === null) { @@ -1090,14 +1098,14 @@ class economic_v2_distribution_service $order_original_price = $this->calculateOrderOriginalPrice( $order_items_by_order_id[$order_id] ?? [], $customer_number, - $department_id, + $distribution_department_id, $created_at ); $groups[$group_key]['original_price'] += $order_original_price; - if (!isset($groups[$group_key]['department_totals'][$department_id])) { - $groups[$group_key]['department_totals'][$department_id] = 0.0; + if (!isset($groups[$group_key]['department_totals'][$distribution_department_id])) { + $groups[$group_key]['department_totals'][$distribution_department_id] = 0.0; } - $groups[$group_key]['department_totals'][$department_id] += $order_original_price; + $groups[$group_key]['department_totals'][$distribution_department_id] += $order_original_price; $groups[$group_key]['order_ids'][] = $order_id; if (!isset($customer_transactions[$customer_number][$order_id])) { @@ -1141,12 +1149,15 @@ class economic_v2_distribution_service } elseif (!$this->isOrderEligible($order)) { continue; } + $distribution_department_id = $system_order_fallback + ? $this->getFallbackDistributionDepartmentId() + : $department_id; $month_key = substr($created_at, 0, 7); - if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) { - $customer_department_month_map[$customer_number][$month_key][$department_id] = 0; + if (!isset($customer_department_month_map[$customer_number][$month_key][$distribution_department_id])) { + $customer_department_month_map[$customer_number][$month_key][$distribution_department_id] = 0; } - $customer_department_month_map[$customer_number][$month_key][$department_id]++; + $customer_department_month_map[$customer_number][$month_key][$distribution_department_id]++; $candidates = $this->buildWashSubscriptionCandidates( $order, @@ -1193,10 +1204,10 @@ class economic_v2_distribution_service ]; } - if (!isset($groups[$group_key]['distribution'][$department_id])) { - $groups[$group_key]['distribution'][$department_id] = 0; + if (!isset($groups[$group_key]['distribution'][$distribution_department_id])) { + $groups[$group_key]['distribution'][$distribution_department_id] = 0; } - $groups[$group_key]['distribution'][$department_id]++; + $groups[$group_key]['distribution'][$distribution_department_id]++; $groups[$group_key]['order_ids'][] = $order_id; $matched_order = true; } @@ -1394,6 +1405,9 @@ class economic_v2_distribution_service ): array { $distribution = []; $department_counts = $customer_department_month_map[$customer_number][$month_key] ?? []; + if (!empty($department_counts)) { + $department_counts = $this->normalizeFallbackDepartmentCounts($department_counts); + } if (!empty($department_counts)) { $total = (float)array_sum($department_counts); foreach ($department_counts as $department_id => $count) { @@ -1402,20 +1416,61 @@ class economic_v2_distribution_service return $distribution; } - $default_department = 1; - try { - $default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment(); - if (!empty($default)) { - $default_department = (int)$default; - } - } catch (Exception $e) { - // fall back to department 1 - } + $customer_default_department_id = $this->getCustomerDefaultDepartmentId($customer_number); + $default_department = $customer_default_department_id !== null && $customer_default_department_id > 0 + ? $customer_default_department_id + : $this->getFallbackDistributionDepartmentId(); $distribution[$default_department] = $monthly_price; return $distribution; } + /** + * @param array $department_counts + * @return array + */ + private function normalizeFallbackDepartmentCounts(array $department_counts): array + { + $normalized = []; + foreach ($department_counts as $department_id => $count) { + $department_id = (int)$department_id; + if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { + $department_id = $this->getFallbackDistributionDepartmentId(); + } + if (!isset($normalized[$department_id])) { + $normalized[$department_id] = 0; + } + $normalized[$department_id] += $count; + } + + return $normalized; + } + + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + try { + $default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment(); + return !empty($default) ? (int)$default : null; + } catch (Exception $e) { + return null; + } + } + + protected function getFallbackDistributionDepartmentId(): int + { + try { + $department_id = (new economic())->getDefaultDistributionDepartmentId(); + } catch (\Throwable $e) { + $department_id = economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + + if ($department_id <= 0 || $department_id === self::SYSTEM_ORDER_DEPARTMENT_ID) { + return economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + + return $department_id; + } + private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array { if (empty($distribution)) { diff --git a/services/nginx/app/classes/workfeed_employee_name_formatter.php b/services/nginx/app/classes/workfeed_employee_name_formatter.php new file mode 100644 index 00000000..db549ff8 --- /dev/null +++ b/services/nginx/app/classes/workfeed_employee_name_formatter.php @@ -0,0 +1,103 @@ + $firstNamePaths + * @param array $lastNamePaths + * @param array $fallbackNamePaths + */ + public static function fromRecord( + mixed $record, + array $firstNamePaths, + array $lastNamePaths, + array $fallbackNamePaths = [], + ?string $employeeId = null + ): ?string { + $firstName = self::firstTextValueByPath($record, $firstNamePaths); + $lastName = self::firstTextValueByPath($record, $lastNamePaths); + + $schemaName = self::joinNameParts($firstName, $lastName); + if ($schemaName !== null) { + return $schemaName; + } + + foreach ($fallbackNamePaths as $path) { + $name = self::normalizeTextValue(self::valueByPath($record, $path)); + if ($name !== null && !self::isMissingDisplayName($name, $employeeId)) { + return $name; + } + } + + return null; + } + + public static function isMissingDisplayName(?string $employeeName, ?string $employeeId = null): bool + { + if ($employeeName === null) { + return true; + } + + if ($employeeId !== null && strcasecmp($employeeName, 'Employee ' . $employeeId) === 0) { + return true; + } + + return strcasecmp($employeeName, 'Unknown employee') === 0; + } + + /** + * @param array $paths + */ + private static function firstTextValueByPath(mixed $record, array $paths): ?string + { + foreach ($paths as $path) { + $value = self::normalizeTextValue(self::valueByPath($record, $path)); + if ($value !== null) { + return $value; + } + } + + return null; + } + + private static function joinNameParts(?string $firstName, ?string $lastName): ?string + { + $name = trim((string)($firstName ?? '') . ' ' . (string)($lastName ?? '')); + + return $name !== '' ? $name : null; + } + + private static function valueByPath(mixed $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; + } + + private static function normalizeTextValue(mixed $value): ?string + { + if (!is_scalar($value)) { + return null; + } + + $normalized = trim((string)$value); + + return $normalized !== '' ? $normalized : null; + } +} diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 2bd0f282..ff186dfd 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -11,6 +11,7 @@ use classes\system_search_document_index; use classes\system_search_economic_customer_index; use classes\system_search_registry; use classes\workfeed; +use classes\workfeed_employee_name_formatter; use classes\xlvask; use classes\slack as Slack; use classes\email as Email; @@ -32,6 +33,7 @@ use routes\moduleWeatherAPIRoute; require_once __DIR__ . '/../classes/economic_transfer_executor.php'; require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php'; require_once __DIR__ . '/../classes/economic_transfer_queue.php'; +require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php'; if (!defined('WD')) { exit; @@ -301,8 +303,27 @@ function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array $employeeId = $employeeIds[0] ?? null; - $employeeName = null; - foreach ([ + $employeeName = workfeed_employee_name_formatter::fromRecord($record, [ + 'firstname', + 'firstName', + 'first_name', + 'employee.firstname', + 'employee.firstName', + 'employee.first_name', + 'user.firstname', + 'user.firstName', + 'user.first_name', + ], [ + 'lastname', + 'lastName', + 'last_name', + 'employee.lastname', + 'employee.lastName', + 'employee.last_name', + 'user.lastname', + 'user.lastName', + 'user.last_name', + ], [ 'employeeName', 'employee.name', 'employee.fullName', @@ -319,36 +340,7 @@ function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array '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; - } + ], $employeeId); return [ 'id' => $employeeId, diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index d0465786..b57885ee 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -8,6 +8,7 @@ use classes\response; use classes\router; use classes\weatherapi; use classes\workfeed; +use classes\workfeed_employee_name_formatter; use classes\workfeed_shift_time_resolver; use DateInterval; use DateTime; @@ -1484,14 +1485,14 @@ class moduleWeatherAPIRoute return null; } - private function normalizeDepartmentName(string $name): string + private static function normalizeDepartmentName(string $name): string { $collapsed = preg_replace('/\s+/', ' ', trim($name)); return strtolower($collapsed ?? trim($name)); } - private function normalizeWorkfeedCollection(array|object $payload): array + private static function normalizeWorkfeedCollection(array|object $payload): array { if (is_array($payload)) { return $payload; @@ -1514,7 +1515,7 @@ class moduleWeatherAPIRoute return []; } - private function normalizeWorkfeedRecord(mixed $record): array + private static function normalizeWorkfeedRecord(mixed $record): array { if (is_array($record)) { return $record; @@ -1526,7 +1527,7 @@ class moduleWeatherAPIRoute return []; } - private function extractWorkfeedDepartmentId(mixed $shift): ?string + private static function extractWorkfeedDepartmentId(mixed $shift): ?string { $record = self::normalizeWorkfeedRecord($shift); @@ -1545,7 +1546,7 @@ class moduleWeatherAPIRoute return $normalized === '' ? null : $normalized; } - private function parseDateTimeValue(mixed $value): ?DateTime + private static function parseDateTimeValue(mixed $value): ?DateTime { if (is_string($value)) { $normalized = trim($value); @@ -1599,7 +1600,7 @@ class moduleWeatherAPIRoute return null; } - private function getNestedRecordValue(array $record, string $path): mixed + private static function getNestedRecordValue(array $record, string $path): mixed { $segments = explode('.', $path); $current = $record; @@ -1627,7 +1628,7 @@ class moduleWeatherAPIRoute return $current; } - private function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime + private static function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime { foreach ($paths as $path) { $value = self::getNestedRecordValue($record, $path); @@ -1640,7 +1641,7 @@ class moduleWeatherAPIRoute return null; } - private function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime + private static function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime { $latest = null; @@ -1659,7 +1660,7 @@ class moduleWeatherAPIRoute return $latest; } - private function hasShiftApproval(array $record): bool + private static function hasShiftApproval(array $record): bool { if (!array_key_exists('approval', $record)) { return false; @@ -1680,7 +1681,7 @@ class moduleWeatherAPIRoute return true; } - private function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime + private static function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime { if (self::hasShiftApproval($record)) { return $shift_end; @@ -1711,7 +1712,7 @@ class moduleWeatherAPIRoute return $update_time; } - private function calculateWorkfeedEmployeeHoursForHour( + private static function calculateWorkfeedEmployeeHoursForHour( array $shifts, string|array $workfeed_department_ids, DateTime $slot_start, @@ -1775,8 +1776,8 @@ class moduleWeatherAPIRoute if (defined('redis')) { try { $employeeNameCache = new redis(); - } catch (Exception) { - $employeeNameCache = null; + } catch (Exception $e) { + // Redis is unavailable, proceed without caching. } } @@ -1822,7 +1823,8 @@ class moduleWeatherAPIRoute $employees = $this->resolveMissingWorkfeedEmployeeNames( $employees, $employeeNameCache, - $resolved_employee_names_by_id + $resolved_employee_names_by_id, + $workfeed ); if ($employees === []) { continue; @@ -1851,7 +1853,7 @@ class moduleWeatherAPIRoute } } - private function calculateWorkfeedEmployeeHoursForHourByEmployee( + private static function calculateWorkfeedEmployeeHoursForHourByEmployee( array $shifts, string|array $workfeed_department_ids, DateTime $slot_start, @@ -1902,7 +1904,11 @@ class moduleWeatherAPIRoute $employee_identity = self::extractWorkfeedEmployeeIdentity($shift); $employee_id = $employee_identity['id']; - $employee_name = self::normalizeShiftTextValue($employee_identity['name'] ?? null) ?? 'Unknown employee'; + $employee_name = self::normalizeShiftTextValue($employee_identity['name'] ?? null); + if ($employee_id === null && $employee_name === null) { + continue; + } + $employee_key = $employee_id ?? ('name:' . strtolower($employee_name)); if (!isset($hours_by_employee_key[$employee_key])) { $hours_by_employee_key[$employee_key] = [ @@ -1929,8 +1935,14 @@ class moduleWeatherAPIRoute })); } - private function resolveMissingWorkfeedEmployeeNames(array $employees, ?redis $employee_name_cache, array &$resolved_names_by_employee_id): array + private function resolveMissingWorkfeedEmployeeNames( + array $employees, + ?redis $employee_name_cache, + array &$resolved_names_by_employee_id, + ?workfeed $workfeed = null + ): array { + $ids_to_fetch = []; foreach ($employees as &$employee) { $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null); if ($employee_id === null) { @@ -1949,6 +1961,35 @@ class moduleWeatherAPIRoute ); } + if ($resolved_names_by_employee_id[$employee_id] === null) { + $ids_to_fetch[$employee_id] = true; + } + } + unset($employee); + + if ($ids_to_fetch !== [] && $workfeed !== null) { + $api_names_by_employee_id = $this->fetchWorkfeedEmployeeDisplayNames( + $workfeed, + array_keys($ids_to_fetch), + $employee_name_cache + ); + + foreach (array_keys($ids_to_fetch) as $employee_id) { + $resolved_names_by_employee_id[$employee_id] = $api_names_by_employee_id[$employee_id] ?? null; + } + } + + 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; + } + $resolved_name = $resolved_names_by_employee_id[$employee_id] ?? null; if ($resolved_name !== null) { $employee['employee_name'] = $resolved_name; @@ -1960,7 +2001,172 @@ class moduleWeatherAPIRoute return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? '')); }); - return $employees; + return array_values(array_filter($employees, static function (array $employee): bool { + $employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null); + $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null); + + return !self::isMissingEmployeeDisplayName($employee_name, $employee_id); + })); + } + + /** + * @param array $employee_ids + * @return array + */ + private function fetchWorkfeedEmployeeDisplayNames(workfeed $workfeed, array $employee_ids, ?redis $employee_name_cache): array + { + $employee_id_lookup = []; + foreach ($employee_ids as $employee_id) { + $normalized = self::normalizeShiftTextValue($employee_id); + if ($normalized !== null) { + $employee_id_lookup[$normalized] = true; + } + } + if ($employee_id_lookup === []) { + return []; + } + + $names_by_employee_id = []; + try { + $employees = self::normalizeWorkfeedCollection($workfeed->listEmployees()); + } catch (Exception) { + $employees = []; + } + + foreach ($employees as $employee) { + $this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache); + } + + foreach (array_keys($employee_id_lookup) as $employee_id) { + if (isset($names_by_employee_id[$employee_id])) { + continue; + } + + try { + $employee = $workfeed->getEmployee($employee_id); + } catch (Exception) { + continue; + } + + $this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache); + } + + return $names_by_employee_id; + } + + /** + * @param array $employee_id_lookup + * @param array $names_by_employee_id + */ + private function appendWorkfeedEmployeeDisplayNames( + mixed $employee, + array $employee_id_lookup, + array &$names_by_employee_id, + ?redis $employee_name_cache + ): void { + foreach (self::extractWorkfeedEmployeeIds($employee) as $employee_id) { + if (!isset($employee_id_lookup[$employee_id])) { + continue; + } + + $employee_name = self::extractWorkfeedEmployeeDisplayName($employee, $employee_id); + if ($employee_name === null) { + continue; + } + + $names_by_employee_id[$employee_id] = $employee_name; + $this->cacheWorkfeedEmployeeDisplayName($employee_name_cache, $employee_id, $employee_name); + } + } + + private static function extractWorkfeedEmployeeDisplayName(mixed $employee, ?string $employee_id = null): ?string + { + return workfeed_employee_name_formatter::fromRecord($employee, [ + 'firstname', + 'firstName', + 'first_name', + 'employee.firstname', + 'employee.firstName', + 'employee.first_name', + 'user.firstname', + 'user.firstName', + 'user.first_name', + ], [ + 'lastname', + 'lastName', + 'last_name', + 'employee.lastname', + 'employee.lastName', + 'employee.last_name', + 'user.lastname', + 'user.lastName', + 'user.last_name', + ], [ + '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', + ], $employee_id); + } + + /** + * @return array + */ + private static function extractWorkfeedEmployeeIds(mixed $employee): array + { + $record = self::normalizeWorkfeedRecord($employee); + if ($record === []) { + return []; + } + + $employee_ids = []; + foreach ([ + 'id', + 'employeeID', + 'employeeId', + 'employee_id', + 'uuid', + 'employee.id', + 'employee.employeeID', + 'employee.employeeId', + 'employee.employee_id', + 'employee.uuid', + 'employeeUUID', + 'employee_uuid', + 'user.id', + 'userId', + ] as $path) { + $employee_id = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path)); + if ($employee_id !== null) { + $employee_ids[$employee_id] = true; + } + } + + return array_keys($employee_ids); + } + + private function cacheWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id, string $employee_name): void + { + if ($employee_name_cache === null) { + return; + } + + try { + $employee_name_cache->cache_workfeed_employee_name($employee_id, $employee_name); + } catch (Exception) { + } } private function fetchCachedWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id): ?string @@ -1978,20 +2184,12 @@ class moduleWeatherAPIRoute return self::isMissingEmployeeDisplayName($employee_name, $employee_id) ? null : $employee_name; } - private function isMissingEmployeeDisplayName(?string $employee_name, ?string $employee_id = null): bool + private static 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; + return workfeed_employee_name_formatter::isMissingDisplayName($employee_name, $employee_id); } - private function extractWorkfeedEmployeeIdentity(mixed $shift): array + private static function extractWorkfeedEmployeeIdentity(mixed $shift): array { $record = self::normalizeWorkfeedRecord($shift); @@ -2017,8 +2215,27 @@ class moduleWeatherAPIRoute } } - $employee_name = null; - foreach ([ + $employee_name = workfeed_employee_name_formatter::fromRecord($record, [ + 'employee.firstname', + 'employee.firstName', + 'employee.first_name', + 'firstname', + 'firstName', + 'first_name', + 'user.firstname', + 'user.firstName', + 'user.first_name', + ], [ + 'employee.lastname', + 'employee.lastName', + 'employee.last_name', + 'lastname', + 'lastName', + 'last_name', + 'user.lastname', + 'user.lastName', + 'user.last_name', + ], [ 'employeeName', 'employee.name', 'employee.fullName', @@ -2035,37 +2252,7 @@ class moduleWeatherAPIRoute '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'; - } + ], $employee_id); return [ 'id' => $employee_id, @@ -2073,7 +2260,7 @@ class moduleWeatherAPIRoute ]; } - private function normalizeShiftTextValue(mixed $value): ?string + private static function normalizeShiftTextValue(mixed $value): ?string { if (!is_scalar($value)) { return null; diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php index bbe56abe..b0abbaac 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php @@ -53,6 +53,8 @@ if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) { public array $subscriptionPrices = []; public array $stubBookedInvoices = []; public array $stubBookedInvoiceLines = []; + public int $fallbackDepartmentId = 8; + public array $customerDefaultDepartments = []; public ?array $includedCustomers = null; public function __construct(?economic_v2_versioning_service $versioning = null) @@ -138,6 +140,16 @@ if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) { return in_array($customer_number, $this->includedCustomers, true); } + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + return $this->customerDefaultDepartments[$customer_number] ?? null; + } + + protected function getFallbackDistributionDepartmentId(): int + { + return $this->fallbackDepartmentId; + } + protected function parseDepartmentMap(array $department_map): array { $parsed = []; @@ -303,7 +315,7 @@ it('redistributes booked department 75 net amounts using fixed pricing and wash expect($result['collective_results']['department_distribution']['2'])->toBe(138.46154); }); -it('keeps classified booked department 75 amounts undistributed when no monthly basis exists', function (): void { +it('assigns classified booked department 75 amounts to fallback when no monthly basis exists', function (): void { $service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService()); $service->stubBookedInvoices = [[ 'bookedInvoiceNumber' => 7002, @@ -336,11 +348,18 @@ it('keeps classified booked department 75 amounts undistributed when no monthly $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); expect($result['customers'])->toHaveCount(1); - expect($result['customers'][0]['meta']['booked_department_75']['booked_groups'][0]['source_category'])->toBe('fixed_pricing'); - expect($result['customers'][0]['meta']['booked_department_75']['distributed_net_amount'])->toBe(0.0); - expect($result['customers'][0]['meta']['booked_department_75']['undistributed_net_amount'])->toBe(60.0); - expect($result['collective_results']['undistributed_net_amount'])->toBe(60.0); + $meta = $result['customers'][0]['meta']['booked_department_75']; + expect($meta['booked_groups'][0]['source_category'])->toBe('fixed_pricing'); + expect($meta['distributed_net_amount'])->toBe(60.0); + expect($meta['undistributed_net_amount'])->toBe(0.0); + expect($meta['department_distribution']['8'])->toBe(60.0); + expect($meta['booked_groups'][0]['department_distribution']['8'])->toBe(60.0); + expect($meta['booked_groups'][0]['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['distributed_net_amount'])->toBe(60.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['department_distribution']['8'])->toBe(60.0); expect(implode("\n", $result['warnings']))->toContain('has no redistribution basis'); + expect(implode("\n", $result['warnings']))->toContain('assigned to fallback department 8'); }); it('keeps unclassified booked department 75 lines undistributed with warnings', function (): void { diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php index 918e5d2f..4bb02608 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php @@ -54,6 +54,8 @@ if (!class_exists('TestableEconomicV2DistributionService')) { public array $stubVersionRows = []; public array $subscriptionPrices = []; public array $versionTableState = []; + public int $fallbackDepartmentId = 8; + public array $customerDefaultDepartments = []; public ?array $includedCustomers = null; public function __construct(?economic_v2_versioning_service $versioning = null) @@ -138,6 +140,16 @@ if (!class_exists('TestableEconomicV2DistributionService')) { return in_array($customer_number, $this->includedCustomers, true); } + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + return $this->customerDefaultDepartments[$customer_number] ?? null; + } + + protected function getFallbackDistributionDepartmentId(): int + { + return $this->fallbackDepartmentId; + } + protected function parseDepartmentMap(array $department_map): array { $parsed = []; @@ -198,7 +210,7 @@ it('runs best-effort backfill when fixed pricing version history is empty', func expect($result['customers'])->toHaveCount(1); }); -it('falls back to system orders for fixed pricing when regular orders yield no customers', function (): void { +it('falls back to system orders for fixed pricing using the configured fallback department', function (): void { $versioning = new FakeEconomicV2DistributionVersioningService(); $versioning->fixedVersion = [ 'id' => 91, @@ -235,11 +247,14 @@ it('falls back to system orders for fixed pricing when regular orders yield no c expect($result['customers'][0]['customer_number'])->toBe(12345); expect($result['customers'][0]['meta']['fixed_pricing']['price'])->toBe(499.95); expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['order_ids'])->toBe([501]); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['department_totals']['8'])->toBe(499.95); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['department_totals'])->not->toHaveKey('10'); + expect($result['collective_results']['total_department_totals']['8'])->toBe(499.95); expect($result['collective_results']['total_fixed_price'])->toBe(499.95); expect($result['warnings'])->toContain('System order fallback used for fixed pricing (department 10).'); }); -it('falls back to system orders for wash subscriptions when regular orders yield no customers', function (): void { +it('falls back to system orders for wash subscriptions using the configured fallback department', function (): void { $versioning = new FakeEconomicV2DistributionVersioningService(); $versioning->subscriptionVersions = [[ 'id' => 42, @@ -277,11 +292,41 @@ it('falls back to system orders for wash subscriptions when regular orders yield expect($result['customers'][0]['customer_number'])->toBe(12345); expect($result['customers'][0]['meta']['subscription']['subscription_total'])->toBe(299.0); expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['reg'])->toBe('AB12345'); - expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['10'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['8'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution'])->not->toHaveKey('10'); + expect($result['collective_results']['subscription_price_department_distribution']['8'])->toBe(299.0); expect($result['collective_results']['total_subscription_price'])->toBe(299.0); expect($result['warnings'])->toContain('System order fallback used for wash subscriptions (department 10).'); }); +it('uses the configured fallback department when a subscription has no customer department basis', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + + $service = new TestableEconomicV2DistributionService($versioning); + $service->subscriptionPrices = [ + 77 => 299.0, + ]; + $service->stubVersionRows = [[ + 'id' => 42, + 'customer_number' => 12345, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'wash_subscription' => 1, + 'source' => 'test.version', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]]; + + $result = $service->getWashSubscriptionsDistribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['8'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution'])->not->toHaveKey('1'); + expect($result['collective_results']['subscription_price_department_distribution']['8'])->toBe(299.0); +}); + it('excludes orphaned customer traces from fixed pricing and customer price distributions', function (): void { $versioning = new FakeEconomicV2DistributionVersioningService(); $versioning->fixedVersion = [ diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php index 560ae8a1..93826bad 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php @@ -38,10 +38,11 @@ it('registers and implements cron warming for workfeed employee name cache', fun 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($cronContent)->toContain('workfeed_employee_name_formatter::fromRecord'); + expect($cronContent)->toContain("'firstname'"); + expect($cronContent)->toContain("'lastname'"); + expect($cronContent)->toContain("'employee.firstname'"); + expect($cronContent)->toContain("'employee.lastname'"); expect($routeContent)->toContain('fetchCachedWorkfeedEmployeeDisplayName'); expect($routeContent)->toContain('get_workfeed_employee_name'); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php index d17f4991..2f1ec50e 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -182,7 +182,46 @@ it('extracts weather employee identity from supported shift payload shapes', fun ]); }); -it('uses an unknown employee label when no employee name is available', function (): void { +it('prefers canonical workfeed employee schema fields over generic employee names', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'id' => 'emp_4', + 'firstname' => 'Dana', + 'lastname' => 'Scully', + 'name' => 'Wrong Name', + ], + 'employeeName' => 'Also Wrong', + ], + ]); + + expect($identity)->toBe([ + 'id' => 'emp_4', + 'name' => 'Dana Scully', + ]); +}); + +it('does not use workfeed schema name fields as employee ids', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'firstname' => 'Fox', + 'lastname' => 'Mulder', + ], + ], + ]); + + expect($identity)->toBe([ + 'id' => null, + 'name' => 'Fox Mulder', + ]); +}); + +it('returns a null employee name when no workfeed employee name is available', function (): void { $route = new moduleWeatherAPIRoute(); $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ @@ -193,10 +232,66 @@ it('uses an unknown employee label when no employee name is available', function expect($identity)->toBe([ 'id' => 'emp_99', - 'name' => 'Unknown employee', + 'name' => null, ]); }); +it('resolves missing workfeed employee names from the employees endpoint', function (): void { + $route = new moduleWeatherAPIRoute(); + + $workfeed = new class extends workfeed { + public function __construct() + { + } + + public function listEmployees(array $filters = []): array|object + { + return [ + (object)[ + 'id' => 'emp_42', + 'firstname' => 'Jane', + 'lastname' => 'Doe', + ], + ]; + } + + public function getEmployee(string $id): object + { + return (object)[]; + } + }; + + $resolvedNames = []; + $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ + [ + 'employee_id' => 'emp_42', + 'employee_name' => null, + 'hours' => 1.0, + ], + ], null, &$resolvedNames, $workfeed]); + + expect($employees)->toBe([[ + 'employee_id' => 'emp_42', + 'employee_name' => 'Jane Doe', + 'hours' => 1.0, + ]]); +}); + +it('filters employee hour rows that cannot be resolved to a workfeed schema name', function (): void { + $route = new moduleWeatherAPIRoute(); + + $resolvedNames = []; + $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ + [ + 'employee_id' => 'emp_missing', + 'employee_name' => null, + 'hours' => 1.0, + ], + ], null, &$resolvedNames, null]); + + expect($employees)->toBe([]); +}); + it('resolves employee display name from cache when shift payload lacks a name', function (): void { $route = new moduleWeatherAPIRoute(); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php new file mode 100644 index 00000000..46321e03 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php @@ -0,0 +1,86 @@ + 'employee_1', + 'firstname' => 'API 2', + 'lastname' => 'Test 2', + ]); + + expect($name)->toBe('API 2 Test 2'); +}); + +it('prefers workfeed schema names over generic display name fields', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employee' => (object)[ + 'id' => 'employee_2', + 'firstname' => 'Jane', + 'lastname' => 'Doe', + 'name' => 'Wrong Name', + ], + 'employeeName' => 'Also Wrong', + ]); + + expect($name)->toBe('Jane Doe'); +}); + +it('falls back to legacy display name fields when schema names are absent', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employeeID' => 'employee_3', + 'employeeName' => 'Legacy Name', + ]); + + expect($name)->toBe('Legacy Name'); +}); + +it('ignores placeholder workfeed display names', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employeeID' => 'employee_4', + 'employeeName' => 'Unknown employee', + ]); + + expect($name)->toBeNull(); +}); + +it('ignores employee id placeholder display names when the id is known', function (): void { + $name = workfeed_employee_name_formatter::fromRecord((object)[ + 'employeeID' => 'employee_5', + 'employeeName' => 'Employee employee_5', + ], [ + 'firstname', + ], [ + 'lastname', + ], [ + 'employeeName', + ], 'employee_5'); + + expect($name)->toBeNull(); +});