getTimestamp() <= $actual_start->getTimestamp()) { return null; } return [ 'actualStart' => $actual_start, 'scheduledEnd' => $scheduled_end, 'actualEnd' => $actual_end, 'hasApproval' => $has_approval, ]; } 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 */ 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 $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; } }