Files
api/services/nginx/app/classes/workfeed_shift_time_resolver.php
T

286 lines
8.3 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;
}
$has_approval = self::hasShiftApproval($record);
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
$can_use_saved_bounds = $has_approval || $update_time !== null;
$actual_start = self::firstDateTimeFromPaths($record, [
'checkIn.time',
'checkIn',
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'approval.originalStart',
]);
if ($actual_start === null && $can_use_saved_bounds) {
$actual_start = self::firstDateTimeFromPaths($record, [
'start',
'startTime',
'from',
]);
}
$scheduled_end = self::firstDateTimeFromPaths($record, [
'approval.originalEnd',
'end',
'endTime',
'to',
]);
$actual_only_end = self::firstDateTimeFromPaths($record, [
'checkOut.time',
'checkOut',
'actualEnd',
'actualEndTime',
'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 && $can_use_saved_bounds) {
$saved_actual_end = self::firstDateTimeFromPaths($record, [
'end',
'endTime',
'to',
]);
}
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;
}
$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' => $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<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;
}
}