mirror of
https://devops.lemonos.cn/lawson/FendxPHP.git
synced 2026-06-15 23:12:49 +08:00
- 创建用户表(users)包含基本信息和认证字段 - 创建角色表(roles)用于权限控制 - 创建权限表(permissions)定义系统权限 - 创建用户角色关联表(user_roles)建立用户与角色关系 - 创建角色权限关联表(role_permissions)建立角色与权限关系 - 创建迁移记录表(migrations)追踪数据库变更 - 添加AdminController提供管理员面板功能 - 实现系统监控、配置管理、缓存清理等功能 - 添加AOP切面编程支持的各种通知类型 - 实现告警管理AlertManager支持多渠道告警 - 添加文档注解接口规范
897 lines
30 KiB
PHP
897 lines
30 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Fendx\I18n\Timezone\DST;
|
|
|
|
use Fendx\I18n\Timezone\DST\Calculator\DSTCalculator;
|
|
use Fendx\I18n\Timezone\DST\Detector\DSTDetector;
|
|
use Fendx\I18n\Timezone\DST\Transition\DSTTransition;
|
|
|
|
class DaylightSavingTimeHandler
|
|
{
|
|
protected DSTCalculator $calculator;
|
|
protected DSTDetector $detector;
|
|
protected DSTTransition $transition;
|
|
protected array $config = [];
|
|
protected array $dstRules = [];
|
|
protected array $transitionCache = [];
|
|
|
|
public function __construct(array $config = [])
|
|
{
|
|
$this->config = array_merge($this->getDefaultConfig(), $config);
|
|
$this->calculator = new DSTCalculator($this->config);
|
|
$this->detector = new DSTDetector($this->config);
|
|
$this->transition = new DSTTransition($this->config);
|
|
$this->dstRules = $this->loadDSTRules();
|
|
}
|
|
|
|
/**
|
|
* Check if DST is active for timezone at specific datetime.
|
|
*/
|
|
public function isDSTActive(string $timezone, \DateTimeInterface $datetime = null): bool
|
|
{
|
|
$datetime = $datetime ?? new \DateTime();
|
|
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$tz = new \DateTimeZone($timezone);
|
|
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
|
|
|
|
return $dt->format('I') === '1';
|
|
}
|
|
|
|
/**
|
|
* Get DST information for timezone at specific datetime.
|
|
*/
|
|
public function getDSTInfo(string $timezone, \DateTimeInterface $datetime = null): array
|
|
{
|
|
$datetime = $datetime ?? new \DateTime();
|
|
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$tz = new \DateTimeZone($timezone);
|
|
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
|
|
|
|
$isDST = $dt->format('I') === '1';
|
|
$offset = $tz->getOffset($dt);
|
|
$abbreviation = $dt->format('T');
|
|
|
|
return [
|
|
'timezone' => $timezone,
|
|
'datetime' => $datetime->format('Y-m-d H:i:s'),
|
|
'is_dst' => $isDST,
|
|
'offset' => $offset,
|
|
'offset_hours' => $offset / 3600,
|
|
'abbreviation' => $abbreviation,
|
|
'dst_offset' => $isDST ? $this->getDSTOffset($timezone) : 0,
|
|
'standard_offset' => $isDST ? $offset - $this->getDSTOffset($timezone) : $offset
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get DST transitions for timezone in specific year.
|
|
*/
|
|
public function getDSTTransitions(string $timezone, int $year = null): array
|
|
{
|
|
$year = $year ?? (int) date('Y');
|
|
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$cacheKey = "{$timezone}_{$year}";
|
|
|
|
if (isset($this->transitionCache[$cacheKey])) {
|
|
return $this->transitionCache[$cacheKey];
|
|
}
|
|
|
|
$tz = new \DateTimeZone($timezone);
|
|
|
|
// Get transitions for the entire year
|
|
$transitions = $tz->getTransitions(
|
|
strtotime("{$year}-01-01"),
|
|
strtotime("{$year}-12-31 23:59:59")
|
|
);
|
|
|
|
$dstTransitions = [];
|
|
|
|
foreach ($transitions as $transition) {
|
|
if ($transition['dst'] !== $transition['isdst']) {
|
|
$dstTransitions[] = [
|
|
'timestamp' => $transition['ts'],
|
|
'datetime' => date('Y-m-d H:i:s', $transition['ts']),
|
|
'offset' => $transition['offset'],
|
|
'is_dst' => $transition['dst'],
|
|
'abbreviation' => $transition['abbr'],
|
|
'type' => $transition['dst'] ? 'start' : 'end',
|
|
'offset_change' => $this->calculateOffsetChange($transition, $tz, $year)
|
|
];
|
|
}
|
|
}
|
|
|
|
$this->transitionCache[$cacheKey] = $dstTransitions;
|
|
|
|
return $dstTransitions;
|
|
}
|
|
|
|
/**
|
|
* Get next DST transition for timezone.
|
|
*/
|
|
public function getNextDSTTransition(string $timezone, \DateTimeInterface $datetime = null): ?array
|
|
{
|
|
$datetime = $datetime ?? new \DateTime();
|
|
|
|
$transitions = $this->getDSTTransitions($timezone, (int) $datetime->format('Y'));
|
|
|
|
$timestamp = $datetime->getTimestamp();
|
|
|
|
foreach ($transitions as $transition) {
|
|
if ($transition['timestamp'] > $timestamp) {
|
|
return $transition;
|
|
}
|
|
}
|
|
|
|
// Check next year if no transitions found this year
|
|
$nextYear = (int) $datetime->format('Y') + 1;
|
|
$nextYearTransitions = $this->getDSTTransitions($timezone, $nextYear);
|
|
|
|
return !empty($nextYearTransitions) ? $nextYearTransitions[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Get previous DST transition for timezone.
|
|
*/
|
|
public function getPreviousDSTTransition(string $timezone, \DateTimeInterface $datetime = null): ?array
|
|
{
|
|
$datetime = $datetime ?? new \DateTime();
|
|
|
|
$transitions = $this->getDSTTransitions($timezone, (int) $datetime->format('Y'));
|
|
|
|
$timestamp = $datetime->getTimestamp();
|
|
$previousTransition = null;
|
|
|
|
foreach ($transitions as $transition) {
|
|
if ($transition['timestamp'] < $timestamp) {
|
|
$previousTransition = $transition;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check previous year if no transitions found this year
|
|
if ($previousTransition === null) {
|
|
$previousYear = (int) $datetime->format('Y') - 1;
|
|
$previousYearTransitions = $this->getDSTTransitions($timezone, $previousYear);
|
|
|
|
return !empty($previousYearTransitions) ? end($previousYearTransitions) : null;
|
|
}
|
|
|
|
return $previousTransition;
|
|
}
|
|
|
|
/**
|
|
* Get DST offset for timezone.
|
|
*/
|
|
public function getDSTOffset(string $timezone): int
|
|
{
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
// Most DST offsets are 1 hour (3600 seconds)
|
|
// Some exceptions exist (like Lord Howe Island with 30 minutes)
|
|
$exceptions = [
|
|
'Australia/Lord_Howe' => 1800, // 30 minutes
|
|
'Antarctica/Macquarie' => 1800, // 30 minutes
|
|
];
|
|
|
|
return $exceptions[$timezone] ?? 3600;
|
|
}
|
|
|
|
/**
|
|
* Check if timezone observes DST.
|
|
*/
|
|
public function observesDST(string $timezone): bool
|
|
{
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
// Check if timezone has any DST transitions in current year
|
|
$transitions = $this->getDSTTransitions($timezone);
|
|
|
|
return !empty($transitions);
|
|
}
|
|
|
|
/**
|
|
* Get DST periods for timezone in specific year.
|
|
*/
|
|
public function getDSTPeriods(string $timezone, int $year = null): array
|
|
{
|
|
$year = $year ?? (int) date('Y');
|
|
$transitions = $this->getDSTTransitions($timezone, $year);
|
|
|
|
$periods = [];
|
|
$startTransition = null;
|
|
|
|
foreach ($transitions as $transition) {
|
|
if ($transition['type'] === 'start') {
|
|
$startTransition = $transition;
|
|
} elseif ($transition['type'] === 'end' && $startTransition) {
|
|
$periods[] = [
|
|
'start' => $startTransition,
|
|
'end' => $transition,
|
|
'duration' => $transition['timestamp'] - $startTransition['timestamp'],
|
|
'duration_hours' => ($transition['timestamp'] - $startTransition['timestamp']) / 3600
|
|
];
|
|
$startTransition = null;
|
|
}
|
|
}
|
|
|
|
return $periods;
|
|
}
|
|
|
|
/**
|
|
* Convert datetime with DST awareness.
|
|
*/
|
|
public function convertWithDST(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone): \DateTime
|
|
{
|
|
if (!$this->isValidTimezone($fromTimezone)) {
|
|
throw new \InvalidArgumentException("Invalid source timezone: {$fromTimezone}");
|
|
}
|
|
|
|
if (!$this->isValidTimezone($toTimezone)) {
|
|
throw new \InvalidArgumentException("Invalid target timezone: {$toTimezone}");
|
|
}
|
|
|
|
$fromTz = new \DateTimeZone($fromTimezone);
|
|
$toTz = new \DateTimeZone($toTimezone);
|
|
|
|
// Create DateTime in source timezone
|
|
$sourceDt = new \DateTime($datetime->format('Y-m-d H:i:s'), $fromTz);
|
|
|
|
// Convert to target timezone (PHP handles DST automatically)
|
|
$targetDt = $sourceDt->setTimezone($toTz);
|
|
|
|
// Add DST metadata
|
|
$targetDt->dstInfo = [
|
|
'source_dst' => $this->isDSTActive($fromTimezone, $datetime),
|
|
'target_dst' => $this->isDSTActive($toTimezone, $targetDt),
|
|
'source_offset' => $fromTz->getOffset($datetime),
|
|
'target_offset' => $toTz->getOffset($targetDt),
|
|
'offset_change' => $toTz->getOffset($targetDt) - $fromTz->getOffset($datetime)
|
|
];
|
|
|
|
return $targetDt;
|
|
}
|
|
|
|
/**
|
|
* Handle ambiguous or non-existent times during DST transitions.
|
|
*/
|
|
public function handleAmbiguousTime(\DateTimeInterface $datetime, string $timezone, string $preference = 'standard'): \DateTime
|
|
{
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$tz = new \DateTimeZone($timezone);
|
|
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
|
|
|
|
// Check if this is an ambiguous time (during fall back transition)
|
|
$previousTransition = $this->getPreviousDSTTransition($timezone, $datetime);
|
|
|
|
if ($previousTransition && $previousTransition['type'] === 'end') {
|
|
$transitionDateTime = new \DateTime($previousTransition['datetime']);
|
|
$transitionDateTime->setTimezone($tz);
|
|
|
|
// Check if our datetime is within the ambiguous hour
|
|
$diff = $datetime->getTimestamp() - $transitionDateTime->getTimestamp();
|
|
|
|
if ($diff >= 0 && $diff < 3600) { // Within the ambiguous hour
|
|
switch ($preference) {
|
|
case 'standard':
|
|
// Use standard time (first occurrence)
|
|
return $dt;
|
|
case 'dst':
|
|
// Use DST time (second occurrence)
|
|
$dt->modify('+1 hour');
|
|
return $dt;
|
|
case 'earlier':
|
|
// Use earlier time
|
|
return $dt;
|
|
case 'later':
|
|
// Use later time
|
|
$dt->modify('+1 hour');
|
|
return $dt;
|
|
default:
|
|
throw new \InvalidArgumentException("Invalid preference: {$preference}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return $dt;
|
|
}
|
|
|
|
/**
|
|
* Handle non-existent time during DST transition.
|
|
*/
|
|
public function handleNonExistentTime(\DateTimeInterface $datetime, string $timezone, string $strategy = 'forward'): \DateTime
|
|
{
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$tz = new \DateTimeZone($timezone);
|
|
|
|
// Check if this is a non-existent time (during spring forward transition)
|
|
$previousTransition = $this->getPreviousDSTTransition($timezone, $datetime);
|
|
|
|
if ($previousTransition && $previousTransition['type'] === 'start') {
|
|
$transitionDateTime = new \DateTime($previousTransition['datetime']);
|
|
$transitionDateTime->setTimezone($tz);
|
|
|
|
// Check if our datetime is within the skipped hour
|
|
$diff = $datetime->getTimestamp() - $transitionDateTime->getTimestamp();
|
|
|
|
if ($diff >= 0 && $diff < 3600) { // Within the skipped hour
|
|
switch ($strategy) {
|
|
case 'forward':
|
|
// Move forward to the next valid time
|
|
return new \DateTime($previousTransition['datetime'], $tz);
|
|
case 'backward':
|
|
// Move backward to the previous valid time
|
|
$dt = new \DateTime($previousTransition['datetime'], $tz);
|
|
$dt->modify('-1 hour');
|
|
return $dt;
|
|
case 'adjust':
|
|
// Adjust to the nearest valid time
|
|
return new \DateTime($previousTransition['datetime'], $tz);
|
|
default:
|
|
throw new \InvalidArgumentException("Invalid strategy: {$strategy}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
|
|
}
|
|
|
|
/**
|
|
* Get DST rules for timezone.
|
|
*/
|
|
public function getDSTRules(string $timezone): array
|
|
{
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
return $this->dstRules[$timezone] ?? $this->generateDSTRules($timezone);
|
|
}
|
|
|
|
/**
|
|
* Calculate DST-aware time difference.
|
|
*/
|
|
public function calculateTimeDifference(\DateTimeInterface $start, \DateTimeInterface $end, string $timezone): array
|
|
{
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$tz = new \DateTimeZone($timezone);
|
|
$startDt = new \DateTime($start->format('Y-m-d H:i:s'), $tz);
|
|
$endDt = new \DateTime($end->format('Y-m-d H:i:s'), $tz);
|
|
|
|
$interval = $endDt->diff($startDt);
|
|
|
|
// Check if DST transitions occurred between the two times
|
|
$transitions = $this->getDSTTransitions($timezone, (int) $start->format('Y'));
|
|
$dstTransitionsInRange = [];
|
|
|
|
foreach ($transitions as $transition) {
|
|
if ($transition['timestamp'] > $start->getTimestamp() &&
|
|
$transition['timestamp'] < $end->getTimestamp()) {
|
|
$dstTransitionsInRange[] = $transition;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'interval' => $interval,
|
|
'total_seconds' => $end->getTimestamp() - $start->getTimestamp(),
|
|
'dst_transitions' => $dstTransitionsInRange,
|
|
'dst_adjusted_seconds' => $this->calculateDSTAdjustedDifference($start, $end, $timezone),
|
|
'has_dst_transition' => !empty($dstTransitionsInRange)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get DST-aware business hours calculation.
|
|
*/
|
|
public function calculateBusinessHours(\DateTimeInterface $datetime, string $timezone, array $businessHours = null): array
|
|
{
|
|
$businessHours = $businessHours ?? $this->config['business_hours'];
|
|
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$tz = new \DateTimeZone($timezone);
|
|
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
|
|
|
|
$isDST = $this->isDSTActive($timezone, $dt);
|
|
$hour = (int) $dt->format('H');
|
|
$dayOfWeek = (int) $dt->format('w');
|
|
|
|
// Adjust business hours for DST if needed
|
|
$adjustedBusinessHours = $businessHours;
|
|
if ($isDST && $this->config['adjust_business_hours_for_dst']) {
|
|
$adjustedBusinessHours = $this->adjustBusinessHoursForDST($businessHours, $timezone);
|
|
}
|
|
|
|
$isBusinessHours = in_array($dayOfWeek, $adjustedBusinessHours['weekdays']) &&
|
|
$hour >= $adjustedBusinessHours['start_hour'] &&
|
|
$hour < $adjustedBusinessHours['end_hour'];
|
|
|
|
return [
|
|
'datetime' => $dt,
|
|
'timezone' => $timezone,
|
|
'is_dst' => $isDST,
|
|
'is_business_hours' => $isBusinessHours,
|
|
'business_hours' => $adjustedBusinessHours,
|
|
'next_business_hour' => $this->getNextBusinessHour($dt, $timezone, $adjustedBusinessHours),
|
|
'previous_business_hour' => $this->getPreviousBusinessHour($dt, $timezone, $adjustedBusinessHours)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Validate DST configuration.
|
|
*/
|
|
public function validateDSTConfig(array $config): array
|
|
{
|
|
$errors = [];
|
|
$warnings = [];
|
|
|
|
// Validate timezone
|
|
if (isset($config['timezone']) && !$this->isValidTimezone($config['timezone'])) {
|
|
$errors[] = "Invalid timezone: {$config['timezone']}";
|
|
}
|
|
|
|
// Validate business hours
|
|
if (isset($config['business_hours'])) {
|
|
$bh = $config['business_hours'];
|
|
if (!isset($bh['start_hour']) || !is_int($bh['start_hour']) || $bh['start_hour'] < 0 || $bh['start_hour'] > 23) {
|
|
$errors[] = "Invalid start_hour in business_hours";
|
|
}
|
|
|
|
if (!isset($bh['end_hour']) || !is_int($bh['end_hour']) || $bh['end_hour'] < 0 || $bh['end_hour'] > 24) {
|
|
$errors[] = "Invalid end_hour in business_hours";
|
|
}
|
|
|
|
if (isset($bh['start_hour']) && isset($bh['end_hour']) && $bh['start_hour'] >= $bh['end_hour']) {
|
|
$warnings[] = "Business hours start_hour should be less than end_hour";
|
|
}
|
|
}
|
|
|
|
// Validate preference settings
|
|
if (isset($config['ambiguous_time_preference'])) {
|
|
$validPreferences = ['standard', 'dst', 'earlier', 'later'];
|
|
if (!in_array($config['ambiguous_time_preference'], $validPreferences)) {
|
|
$errors[] = "Invalid ambiguous_time_preference";
|
|
}
|
|
}
|
|
|
|
if (isset($config['non_existent_time_strategy'])) {
|
|
$validStrategies = ['forward', 'backward', 'adjust'];
|
|
if (!in_array($config['non_existent_time_strategy'], $validStrategies)) {
|
|
$errors[] = "Invalid non_existent_time_strategy";
|
|
}
|
|
}
|
|
|
|
return [
|
|
'valid' => empty($errors),
|
|
'errors' => $errors,
|
|
'warnings' => $warnings
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Generate DST report for timezone.
|
|
*/
|
|
public function generateDSTReport(string $timezone, int $year = null): array
|
|
{
|
|
$year = $year ?? (int) date('Y');
|
|
|
|
if (!$this->isValidTimezone($timezone)) {
|
|
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
|
|
}
|
|
|
|
$transitions = $this->getDSTTransitions($timezone, $year);
|
|
$periods = $this->getDSTPeriods($timezone, $year);
|
|
$observesDST = $this->observesDST($timezone);
|
|
|
|
$report = [
|
|
'timezone' => $timezone,
|
|
'year' => $year,
|
|
'observes_dst' => $observesDST,
|
|
'total_transitions' => count($transitions),
|
|
'total_periods' => count($periods),
|
|
'dst_offset' => $this->getDSTOffset($timezone),
|
|
'current_status' => $this->getDSTInfo($timezone),
|
|
'transitions' => $transitions,
|
|
'periods' => $periods
|
|
];
|
|
|
|
if ($observesDST) {
|
|
$report['statistics'] = [
|
|
'total_dst_hours' => $this->calculateTotalDSTHours($timezone, $year),
|
|
'dst_percentage' => $this->calculateDSTPercentage($timezone, $year),
|
|
'longest_dst_period' => $this->getLongestDSTPeriod($timezone, $year),
|
|
'shortest_dst_period' => $this->getShortestDSTPeriod($timezone, $year)
|
|
];
|
|
}
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* Check if timezone is valid.
|
|
*/
|
|
protected function isValidTimezone(string $timezone): bool
|
|
{
|
|
return in_array($timezone, \DateTimeZone::listIdentifiers());
|
|
}
|
|
|
|
/**
|
|
* Load DST rules.
|
|
*/
|
|
protected function loadDSTRules(): array
|
|
{
|
|
return [
|
|
// US DST rules (historical and current)
|
|
'America/New_York' => [
|
|
'start_month' => 3,
|
|
'start_day' => 'second_sunday',
|
|
'start_time' => '02:00',
|
|
'end_month' => 11,
|
|
'end_day' => 'first_sunday',
|
|
'end_time' => '02:00',
|
|
'offset' => 3600
|
|
],
|
|
'America/Chicago' => [
|
|
'start_month' => 3,
|
|
'start_day' => 'second_sunday',
|
|
'start_time' => '02:00',
|
|
'end_month' => 11,
|
|
'end_day' => 'first_sunday',
|
|
'end_time' => '02:00',
|
|
'offset' => 3600
|
|
],
|
|
'America/Denver' => [
|
|
'start_month' => 3,
|
|
'start_day' => 'second_sunday',
|
|
'start_time' => '02:00',
|
|
'end_month' => 11,
|
|
'end_day' => 'first_sunday',
|
|
'end_time' => '02:00',
|
|
'offset' => 3600
|
|
],
|
|
'America/Los_Angeles' => [
|
|
'start_month' => 3,
|
|
'start_day' => 'second_sunday',
|
|
'start_time' => '02:00',
|
|
'end_month' => 11,
|
|
'end_day' => 'first_sunday',
|
|
'end_time' => '02:00',
|
|
'offset' => 3600
|
|
],
|
|
// European DST rules
|
|
'Europe/London' => [
|
|
'start_month' => 3,
|
|
'start_day' => 'last_sunday',
|
|
'start_time' => '01:00',
|
|
'end_month' => 10,
|
|
'end_day' => 'last_sunday',
|
|
'end_time' => '01:00',
|
|
'offset' => 3600
|
|
],
|
|
'Europe/Paris' => [
|
|
'start_month' => 3,
|
|
'start_day' => 'last_sunday',
|
|
'start_time' => '01:00',
|
|
'end_month' => 10,
|
|
'end_day' => 'last_sunday',
|
|
'end_time' => '01:00',
|
|
'offset' => 3600
|
|
],
|
|
'Europe/Berlin' => [
|
|
'start_month' => 3,
|
|
'start_day' => 'last_sunday',
|
|
'start_time' => '01:00',
|
|
'end_month' => 10,
|
|
'end_day' => 'last_sunday',
|
|
'end_time' => '01:00',
|
|
'offset' => 3600
|
|
]
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Generate DST rules for timezone.
|
|
*/
|
|
protected function generateDSTRules(string $timezone): array
|
|
{
|
|
// Try to infer rules from transitions
|
|
$transitions = $this->getDSTTransitions($timezone);
|
|
|
|
if (empty($transitions)) {
|
|
return [
|
|
'observes_dst' => false
|
|
];
|
|
}
|
|
|
|
// Analyze transitions to generate rules
|
|
$rules = ['observes_dst' => true];
|
|
|
|
// This is a simplified rule generation
|
|
// In practice, you'd want more sophisticated analysis
|
|
foreach ($transitions as $transition) {
|
|
$dt = new \DateTime($transition['datetime']);
|
|
|
|
if ($transition['type'] === 'start') {
|
|
$rules['start_month'] = (int) $dt->format('m');
|
|
$rules['start_day'] = 'unknown'; // Would need more analysis
|
|
$rules['start_time'] = $dt->format('H:i');
|
|
} elseif ($transition['type'] === 'end') {
|
|
$rules['end_month'] = (int) $dt->format('m');
|
|
$rules['end_day'] = 'unknown'; // Would need more analysis
|
|
$rules['end_time'] = $dt->format('H:i');
|
|
}
|
|
}
|
|
|
|
$rules['offset'] = $this->getDSTOffset($timezone);
|
|
|
|
return $rules;
|
|
}
|
|
|
|
/**
|
|
* Calculate offset change for transition.
|
|
*/
|
|
protected function calculateOffsetChange(array $transition, \DateTimeZone $tz, int $year): int
|
|
{
|
|
// Get offset before and after transition
|
|
$before = new \DateTime($transition['datetime'], $tz);
|
|
$before->modify('-1 second');
|
|
|
|
$after = new \DateTime($transition['datetime'], $tz);
|
|
$after->modify('+1 second');
|
|
|
|
return $after->getOffset() - $before->getOffset();
|
|
}
|
|
|
|
/**
|
|
* Calculate DST-adjusted time difference.
|
|
*/
|
|
protected function calculateDSTAdjustedDifference(\DateTimeInterface $start, \DateTimeInterface $end, string $timezone): int
|
|
{
|
|
$tz = new \DateTimeZone($timezone);
|
|
$startDt = new \DateTime($start->format('Y-m-d H:i:s'), $tz);
|
|
$endDt = new \DateTime($end->format('Y-m-d H:i:s'), $tz);
|
|
|
|
// Calculate actual difference in seconds
|
|
return $endDt->getTimestamp() - $startDt->getTimestamp();
|
|
}
|
|
|
|
/**
|
|
* Adjust business hours for DST.
|
|
*/
|
|
protected function adjustBusinessHoursForDST(array $businessHours, string $timezone): array
|
|
{
|
|
// Some businesses may want to adjust their hours during DST
|
|
// This is a placeholder for more complex logic
|
|
return $businessHours;
|
|
}
|
|
|
|
/**
|
|
* Get next business hour.
|
|
*/
|
|
protected function getNextBusinessHour(\DateTime $datetime, string $timezone, array $businessHours): \DateTime
|
|
{
|
|
$next = clone $datetime;
|
|
$dayOfWeek = (int) $next->format('w');
|
|
|
|
// Move to next business day if needed
|
|
while (!in_array($dayOfWeek, $businessHours['weekdays'])) {
|
|
$next->modify('+1 day');
|
|
$dayOfWeek = (int) $next->format('w');
|
|
}
|
|
|
|
// Set to start of business hours
|
|
$next->setTime($businessHours['start_hour'], 0, 0);
|
|
|
|
return $next;
|
|
}
|
|
|
|
/**
|
|
* Get previous business hour.
|
|
*/
|
|
protected function getPreviousBusinessHour(\DateTime $datetime, string $timezone, array $businessHours): \DateTime
|
|
{
|
|
$previous = clone $datetime;
|
|
$dayOfWeek = (int) $previous->format('w');
|
|
|
|
// Move to previous business day if needed
|
|
while (!in_array($dayOfWeek, $businessHours['weekdays'])) {
|
|
$previous->modify('-1 day');
|
|
$dayOfWeek = (int) $previous->format('w');
|
|
}
|
|
|
|
// Set to end of business hours
|
|
$previous->setTime($businessHours['end_hour'] - 1, 59, 59);
|
|
|
|
return $previous;
|
|
}
|
|
|
|
/**
|
|
* Calculate total DST hours in year.
|
|
*/
|
|
protected function calculateTotalDSTHours(string $timezone, int $year): int
|
|
{
|
|
$periods = $this->getDSTPeriods($timezone, $year);
|
|
$totalHours = 0;
|
|
|
|
foreach ($periods as $period) {
|
|
$totalHours += $period['duration_hours'];
|
|
}
|
|
|
|
return (int) $totalHours;
|
|
}
|
|
|
|
/**
|
|
* Calculate DST percentage for year.
|
|
*/
|
|
protected function calculateDSTPercentage(string $timezone, int $year): float
|
|
{
|
|
$totalHours = $this->calculateTotalDSTHours($timezone, $year);
|
|
$yearHours = ($this->isLeapYear($year) ? 366 : 365) * 24;
|
|
|
|
return ($totalHours / $yearHours) * 100;
|
|
}
|
|
|
|
/**
|
|
* Check if year is leap year.
|
|
*/
|
|
protected function isLeapYear(int $year): bool
|
|
{
|
|
return ($year % 4 === 0 && $year % 100 !== 0) || ($year % 400 === 0);
|
|
}
|
|
|
|
/**
|
|
* Get longest DST period.
|
|
*/
|
|
protected function getLongestDSTPeriod(string $timezone, int $year): ?array
|
|
{
|
|
$periods = $this->getDSTPeriods($timezone, $year);
|
|
|
|
if (empty($periods)) {
|
|
return null;
|
|
}
|
|
|
|
$longest = $periods[0];
|
|
|
|
foreach ($periods as $period) {
|
|
if ($period['duration_hours'] > $longest['duration_hours']) {
|
|
$longest = $period;
|
|
}
|
|
}
|
|
|
|
return $longest;
|
|
}
|
|
|
|
/**
|
|
* Get shortest DST period.
|
|
*/
|
|
protected function getShortestDSTPeriod(string $timezone, int $year): ?array
|
|
{
|
|
$periods = $this->getDSTPeriods($timezone, $year);
|
|
|
|
if (empty($periods)) {
|
|
return null;
|
|
}
|
|
|
|
$shortest = $periods[0];
|
|
|
|
foreach ($periods as $period) {
|
|
if ($period['duration_hours'] < $shortest['duration_hours']) {
|
|
$shortest = $period;
|
|
}
|
|
}
|
|
|
|
return $shortest;
|
|
}
|
|
|
|
/**
|
|
* Clear transition cache.
|
|
*/
|
|
public function clearCache(): void
|
|
{
|
|
$this->transitionCache = [];
|
|
}
|
|
|
|
/**
|
|
* Get default configuration.
|
|
*/
|
|
protected function getDefaultConfig(): array
|
|
{
|
|
return [
|
|
'business_hours' => [
|
|
'weekdays' => [1, 2, 3, 4, 5], // Monday to Friday
|
|
'start_hour' => 9,
|
|
'end_hour' => 17
|
|
],
|
|
'adjust_business_hours_for_dst' => false,
|
|
'ambiguous_time_preference' => 'standard',
|
|
'non_existent_time_strategy' => 'forward',
|
|
'cache_enabled' => true,
|
|
'cache_ttl' => 3600
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get configuration.
|
|
*/
|
|
public function getConfig(): array
|
|
{
|
|
return $this->config;
|
|
}
|
|
|
|
/**
|
|
* Set configuration.
|
|
*/
|
|
public function setConfig(array $config): void
|
|
{
|
|
$this->config = array_merge($this->config, $config);
|
|
}
|
|
|
|
/**
|
|
* Create DST handler instance.
|
|
*/
|
|
public static function create(array $config = []): self
|
|
{
|
|
return new self($config);
|
|
}
|
|
|
|
/**
|
|
* Create for US timezones.
|
|
*/
|
|
public static function forUS(): self
|
|
{
|
|
return new self([
|
|
'business_hours' => [
|
|
'weekdays' => [1, 2, 3, 4, 5],
|
|
'start_hour' => 9,
|
|
'end_hour' => 17
|
|
],
|
|
'ambiguous_time_preference' => 'standard',
|
|
'non_existent_time_strategy' => 'forward'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Create for European timezones.
|
|
*/
|
|
public static function forEurope(): self
|
|
{
|
|
return new self([
|
|
'business_hours' => [
|
|
'weekdays' => [1, 2, 3, 4, 5],
|
|
'start_hour' => 9,
|
|
'end_hour' => 17
|
|
],
|
|
'ambiguous_time_preference' => 'standard',
|
|
'non_existent_time_strategy' => 'forward'
|
|
]);
|
|
}
|
|
}
|