Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
259 lines
7.4 KiB
PHP
259 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use DateTime;
|
|
use DateTimeZone;
|
|
use Exception;
|
|
|
|
final class workfeed_shift_time_resolver
|
|
{
|
|
private const MAX_UNAPPROVED_EXTENSION_SECONDS = 6 * 3600;
|
|
private const MAX_UNAPPROVED_SHIFT_SPAN_SECONDS = 24 * 3600;
|
|
|
|
/**
|
|
* @return array{actualStart:DateTime,scheduledEnd:DateTime,actualEnd:DateTime,hasApproval:bool}|null
|
|
*/
|
|
public static function resolveShiftTiming(mixed $record_value): ?array
|
|
{
|
|
$record = self::normalizeRecord($record_value);
|
|
if ($record === []) {
|
|
return null;
|
|
}
|
|
|
|
$actual_start = self::firstDateTimeFromPaths($record, [
|
|
'actualStart',
|
|
'actualStartTime',
|
|
'clockIn',
|
|
'clockInTime',
|
|
'start',
|
|
'startTime',
|
|
'from',
|
|
'approval.originalStart',
|
|
]);
|
|
$scheduled_end = self::firstDateTimeFromPaths($record, [
|
|
'approval.originalEnd',
|
|
'end',
|
|
'endTime',
|
|
'to',
|
|
]);
|
|
$actual_only_end = self::firstDateTimeFromPaths($record, [
|
|
'actualEnd',
|
|
'actualEndTime',
|
|
'clockOut',
|
|
'clockOutTime',
|
|
]);
|
|
$current_end = self::firstDateTimeFromPaths($record, [
|
|
'end',
|
|
'endTime',
|
|
'to',
|
|
]);
|
|
$saved_actual_end = $actual_only_end ?? $current_end;
|
|
|
|
if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
|
|
return null;
|
|
}
|
|
|
|
$actual_end = self::resolveActualEnd($record, $actual_start, $scheduled_end, $saved_actual_end, $actual_only_end);
|
|
if ($actual_end->getTimestamp() <= $actual_start->getTimestamp()) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'actualStart' => $actual_start,
|
|
'scheduledEnd' => $scheduled_end,
|
|
'actualEnd' => $actual_end,
|
|
'hasApproval' => self::hasShiftApproval($record),
|
|
];
|
|
}
|
|
|
|
public static function calculateOvertimeHoursInRange(mixed $record_value, DateTime $range_start, DateTime $range_end_exclusive): float
|
|
{
|
|
$timing = self::resolveShiftTiming($record_value);
|
|
if ($timing === null) {
|
|
return 0.0;
|
|
}
|
|
|
|
$scheduled_end_ts = $timing['scheduledEnd']->getTimestamp();
|
|
$actual_end_ts = $timing['actualEnd']->getTimestamp();
|
|
if ($actual_end_ts <= $scheduled_end_ts) {
|
|
return 0.0;
|
|
}
|
|
|
|
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
|
|
$overtime_end_ts = min($actual_end_ts, $range_end_exclusive->getTimestamp());
|
|
if ($overtime_end_ts <= $overtime_start_ts) {
|
|
return 0.0;
|
|
}
|
|
|
|
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private static function normalizeRecord(mixed $record): array
|
|
{
|
|
if (is_array($record)) {
|
|
return $record;
|
|
}
|
|
if (is_object($record)) {
|
|
return get_object_vars($record);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private static function getNestedRecordValue(array $record, string $path): mixed
|
|
{
|
|
$segments = explode('.', $path);
|
|
$current = $record;
|
|
|
|
foreach ($segments as $segment) {
|
|
if (is_array($current)) {
|
|
if (!array_key_exists($segment, $current)) {
|
|
return null;
|
|
}
|
|
$current = $current[$segment];
|
|
continue;
|
|
}
|
|
|
|
if (is_object($current)) {
|
|
if (!property_exists($current, $segment)) {
|
|
return null;
|
|
}
|
|
$current = $current->$segment;
|
|
continue;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return $current;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,string> $paths
|
|
*/
|
|
private static function firstDateTimeFromPaths(array $record, array $paths): ?DateTime
|
|
{
|
|
foreach ($paths as $path) {
|
|
$parsed = self::parseDateTimeValue(self::getNestedRecordValue($record, $path));
|
|
if ($parsed !== null) {
|
|
return $parsed;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function parseDateTimeValue(mixed $value): ?DateTime
|
|
{
|
|
if (is_string($value)) {
|
|
$normalized = trim($value);
|
|
if ($normalized === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new DateTime($normalized);
|
|
} catch (Exception) {
|
|
if (!is_numeric($normalized)) {
|
|
return null;
|
|
}
|
|
$value = (float)$normalized;
|
|
}
|
|
}
|
|
|
|
if (is_int($value) || is_float($value)) {
|
|
if (!is_finite((float)$value)) {
|
|
return null;
|
|
}
|
|
|
|
$timestamp = (float)$value;
|
|
if ($timestamp > 9999999999) {
|
|
$timestamp /= 1000;
|
|
}
|
|
|
|
try {
|
|
$date = new DateTime('@' . (string)(int)round($timestamp));
|
|
$date->setTimezone(new DateTimeZone('UTC'));
|
|
return $date;
|
|
} catch (Exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
$record = self::normalizeRecord($value);
|
|
foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
|
|
if (!array_key_exists($key, $record)) {
|
|
continue;
|
|
}
|
|
|
|
$parsed = self::parseDateTimeValue($record[$key]);
|
|
if ($parsed !== null) {
|
|
return $parsed;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function hasShiftApproval(array $record): bool
|
|
{
|
|
if (!array_key_exists('approval', $record)) {
|
|
return false;
|
|
}
|
|
|
|
$approval = $record['approval'];
|
|
if ($approval === null) {
|
|
return false;
|
|
}
|
|
if (is_array($approval)) {
|
|
return $approval !== [];
|
|
}
|
|
if (is_object($approval)) {
|
|
return get_object_vars($approval) !== [];
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static function resolveActualEnd(
|
|
array $record,
|
|
DateTime $actual_start,
|
|
DateTime $scheduled_end,
|
|
DateTime $saved_actual_end,
|
|
?DateTime $actual_only_end
|
|
): DateTime {
|
|
if (self::hasShiftApproval($record) || $actual_only_end !== null) {
|
|
return $saved_actual_end;
|
|
}
|
|
|
|
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
|
|
if ($update_time === null) {
|
|
return $saved_actual_end;
|
|
}
|
|
|
|
$shift_start_ts = $actual_start->getTimestamp();
|
|
$scheduled_end_ts = $scheduled_end->getTimestamp();
|
|
$saved_actual_end_ts = $saved_actual_end->getTimestamp();
|
|
$update_ts = $update_time->getTimestamp();
|
|
$fallback_base_ts = max($saved_actual_end_ts, $scheduled_end_ts);
|
|
|
|
if ($update_ts <= $fallback_base_ts) {
|
|
return $saved_actual_end;
|
|
}
|
|
|
|
// Only use updateTime as an overtime hint when no explicit actual end was saved.
|
|
if (($update_ts - $scheduled_end_ts) > self::MAX_UNAPPROVED_EXTENSION_SECONDS) {
|
|
return $saved_actual_end;
|
|
}
|
|
if (($update_ts - $shift_start_ts) > self::MAX_UNAPPROVED_SHIFT_SPAN_SECONDS) {
|
|
return $saved_actual_end;
|
|
}
|
|
|
|
return $update_time;
|
|
}
|
|
}
|