Introduced a new `getAllTransactionIds` utility function to better handle filtering of transaction IDs. Replaced outdated `start`/`end` time properties with `checkIn`/`checkOut` objects for shift records, alongside added validation in tests to exclude shifts without punches. Enhanced invoicing tests to ensure flags remain visible even for excluded transactions.
255 lines
7.3 KiB
PHP
255 lines
7.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;
|
|
}
|
|
|
|
$actual_start = self::firstDateTimeFromPaths($record, [
|
|
'checkIn.time',
|
|
'checkIn',
|
|
'actualStart',
|
|
'actualStartTime',
|
|
'clockIn',
|
|
'clockInTime',
|
|
'approval.originalStart',
|
|
]);
|
|
$scheduled_end = self::firstDateTimeFromPaths($record, [
|
|
'approval.originalEnd',
|
|
'end',
|
|
'endTime',
|
|
'to',
|
|
]);
|
|
$actual_only_end = self::firstDateTimeFromPaths($record, [
|
|
'checkOut.time',
|
|
'checkOut',
|
|
'actualEnd',
|
|
'actualEndTime',
|
|
'clockOut',
|
|
'clockOutTime',
|
|
]);
|
|
$saved_actual_end = $actual_only_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;
|
|
}
|
|
}
|