feat: due resolution and cascade math
Also: StoresUtc trait normalizes datetime casts to UTC (controller ruling). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
23
core/app/Models/Concerns/StoresUtc.php
Normal file
23
core/app/Models/Concerns/StoresUtc.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Eloquent форматирует datetime без смещения, поэтому Carbon в чужом поясе
|
||||
* записался бы как «те же цифры в UTC». Приводим к UTC при присвоении.
|
||||
*/
|
||||
trait StoresUtc
|
||||
{
|
||||
public function setAttribute($key, $value)
|
||||
{
|
||||
if ($value instanceof DateTimeInterface
|
||||
&& in_array($this->getCasts()[$key] ?? null, ['datetime', 'immutable_datetime'], true)) {
|
||||
$value = CarbonImmutable::instance($value)->utc();
|
||||
}
|
||||
|
||||
return parent::setAttribute($key, $value);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\DeliveryResult;
|
||||
use App\Models\Concerns\StoresUtc;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Delivery extends Model
|
||||
{
|
||||
use StoresUtc;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $attributes = ['meta' => '{}'];
|
||||
|
||||
@@ -5,12 +5,15 @@ namespace App\Models;
|
||||
use App\Enums\AfterDue;
|
||||
use App\Enums\DueMode;
|
||||
use App\Enums\EventState;
|
||||
use App\Models\Concerns\StoresUtc;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
use StoresUtc;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
|
||||
27
core/app/Scheduling/Cascade.php
Normal file
27
core/app/Scheduling/Cascade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Scheduling;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class Cascade
|
||||
{
|
||||
/**
|
||||
* Точки каскада между стартом и днём срока: старт, затем каждый раз, когда
|
||||
* до срока остаётся вдвое меньше дней, до одного дня. Обе даты — локальные
|
||||
* полуночи в одном поясе. Возвращает локальные полуночи по возрастанию.
|
||||
*
|
||||
* @return list<CarbonImmutable>
|
||||
*/
|
||||
public static function points(CarbonImmutable $fireStart, CarbonImmutable $dueDay): array
|
||||
{
|
||||
$d = (int) round($fireStart->diffInDays($dueDay));
|
||||
$points = [];
|
||||
while ($d >= 1) {
|
||||
$points[] = $dueDay->subDays($d);
|
||||
$d = intdiv($d, 2);
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
}
|
||||
16
core/app/Scheduling/Due.php
Normal file
16
core/app/Scheduling/Due.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Scheduling;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/** Срок события, приведённый к поясу пользователя. */
|
||||
final class Due
|
||||
{
|
||||
public function __construct(
|
||||
/** Локальная полночь дня срока. */
|
||||
public readonly CarbonImmutable $day,
|
||||
/** Момент срока. Для события «весь день» — следующая полночь. */
|
||||
public readonly CarbonImmutable $moment,
|
||||
) {}
|
||||
}
|
||||
62
core/app/Scheduling/DueResolver.php
Normal file
62
core/app/Scheduling/DueResolver.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Scheduling;
|
||||
|
||||
use App\Enums\DueMode;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class DueResolver
|
||||
{
|
||||
public const WHOLE_DAY = '24:00:00';
|
||||
|
||||
public function resolve(Event $event, User $user): Due
|
||||
{
|
||||
return match ($event->due_mode) {
|
||||
DueMode::Fixed => $this->fixed($event, $user),
|
||||
DueMode::Local => $this->local($event, $user),
|
||||
};
|
||||
}
|
||||
|
||||
public function fireStart(Event $event, User $user): CarbonImmutable
|
||||
{
|
||||
return self::localMidnight($event->fire_on->format('Y-m-d'), $user->tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Старт дня срока. Обычно локальная полночь дня срока. Если момент срока не
|
||||
* позже начала активных часов, первое окно дня уже опоздало бы — тогда старт
|
||||
* за два часа до тихих часов накануне (два последних часовых окна вечера).
|
||||
*/
|
||||
public function todayStart(Due $due, User $user): CarbonImmutable
|
||||
{
|
||||
if ($due->moment->lte($user->activeStart($due->day))) {
|
||||
return $user->activeEnd($due->day->subDay())->subHours(2);
|
||||
}
|
||||
|
||||
return $due->day;
|
||||
}
|
||||
|
||||
public static function localMidnight(string $ymd, string $tz): CarbonImmutable
|
||||
{
|
||||
return CarbonImmutable::createFromFormat('Y-m-d', $ymd, $tz)->startOfDay();
|
||||
}
|
||||
|
||||
private function local(Event $event, User $user): Due
|
||||
{
|
||||
$day = self::localMidnight($event->due_date->format('Y-m-d'), $user->tz);
|
||||
$moment = $event->due_time === self::WHOLE_DAY
|
||||
? $day->addDay()
|
||||
: $day->setTimeFromTimeString($event->due_time);
|
||||
|
||||
return new Due($day, $moment);
|
||||
}
|
||||
|
||||
private function fixed(Event $event, User $user): Due
|
||||
{
|
||||
$moment = $event->due_instant->setTimezone($user->tz);
|
||||
|
||||
return new Due($moment->startOfDay(), $moment);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user