Refactor employee name handling to utilize workfeed_employee_name_formatter for improved name resolution and fallback logic

This commit is contained in:
Jeppe Bundgaard
2026-05-18 10:59:23 +02:00
parent f53b99ad94
commit 3261ed8414
9 changed files with 714 additions and 131 deletions
@@ -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<int|string,int|float> $department_counts
* @return array<int,int|float>
*/
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)) {
@@ -0,0 +1,103 @@
<?php
namespace classes;
final class workfeed_employee_name_formatter
{
/**
* @param array<int,string> $firstNamePaths
* @param array<int,string> $lastNamePaths
* @param array<int,string> $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<int,string> $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;
}
}
+24 -32
View File
@@ -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,
@@ -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<int,string> $employee_ids
* @return array<string,string>
*/
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<string,bool> $employee_id_lookup
* @param array<string,string> $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<int,string>
*/
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;
@@ -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 {
@@ -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 = [
@@ -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');
@@ -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();
@@ -0,0 +1,86 @@
<?php
app_require('classes/workfeed_employee_name_formatter.php');
use classes\workfeed_employee_name_formatter;
function workfeed_employee_name_from_record(mixed $record): ?string
{
return workfeed_employee_name_formatter::fromRecord($record, [
'firstname',
'firstName',
'first_name',
'employee.firstname',
'employee.firstName',
'employee.first_name',
], [
'lastname',
'lastName',
'last_name',
'employee.lastname',
'employee.lastName',
'employee.last_name',
], [
'employeeName',
'employee.name',
'name',
'displayName',
]);
}
it('formats workfeed employee display names from the documented firstname lastname schema', function (): void {
$name = workfeed_employee_name_from_record((object)[
'id' => '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();
});