feat: state, window and quiet rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 06:39:23 -03:00
parent e48e48aa4a
commit c2fa8aa00d
8 changed files with 420 additions and 1 deletions

View File

@@ -11,7 +11,11 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(\App\Scheduling\WindowResolver::class, fn () => new \App\Scheduling\WindowResolver(
config('hado.fallback_day_minutes'),
config('hado.fallback_hour_minutes'),
config('hado.fallback_hours'),
));
}
/**

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Scheduling;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use Carbon\CarbonImmutable;
/** «Помню»: до какого момента молчать, по текущему состоянию. */
final class QuietRule
{
public function __construct(private readonly DueResolver $due) {}
public function afterAck(Event $event, User $user, EventState $state, Due $due, CarbonImmutable $now): CarbonImmutable
{
return match ($state) {
EventState::Preparing => $this->nextCascadePoint($event, $user, $due, $now),
EventState::Today => $now->startOfHour()->addHours(2),
EventState::Overdue => $now->startOfDay()->addDays(2),
default => $now,
};
}
private function nextCascadePoint(Event $event, User $user, Due $due, CarbonImmutable $now): CarbonImmutable
{
$today = $now->startOfDay();
foreach (Cascade::points($this->due->fireStart($event, $user), $due->day) as $point) {
if ($point->gt($today)) {
return $point;
}
}
return $this->due->todayStart($due, $user);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Scheduling;
use App\Enums\AfterDue;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use Carbon\CarbonImmutable;
/**
* Состояние, которое следует из дат события и «сейчас». Используется и кроном
* (переходы), и upsert-ом (пересчёт). Терминальные Done/Withdrawn ставятся
* только действиями, сюда не входят.
*/
final class StateResolver
{
public function __construct(private readonly DueResolver $due) {}
public function resolve(Event $event, User $user, CarbonImmutable $now): EventState
{
$due = $this->due->resolve($event, $user);
if ($now->gte($due->moment)) {
return $event->after_due === AfterDue::Keep ? EventState::Overdue : EventState::Expired;
}
if ($now->gte($this->due->todayStart($due, $user))) {
return EventState::Today;
}
$fireStart = $this->due->fireStart($event, $user);
if ($fireStart->lt($due->day) && $now->gte($fireStart)) {
return EventState::Preparing;
}
return EventState::Scheduled;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Scheduling;
use Carbon\CarbonImmutable;
/** Окно доставки: внутри него ядро ищет момент, когда пользователь доступен. */
final class Window
{
public function __construct(
public readonly CarbonImmutable $start,
public readonly CarbonImmutable $end,
/** Разрешена ли доставка «во все каналы наугад», если никто не present. */
public readonly bool $fallbackAllowed,
/** С какого момента fallback срабатывает (конец окна минус запас). */
public readonly CarbonImmutable $fallbackAt,
) {}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Scheduling;
use App\Enums\EventState;
use App\Models\User;
use Carbon\CarbonImmutable;
final class WindowResolver
{
/** @param list<int> $fallbackHours */
public function __construct(
private readonly int $dayMarginMinutes,
private readonly int $hourMarginMinutes,
private readonly array $fallbackHours,
) {}
/** Окно, открытое в момент $now, или null (тихие часы / срок прошёл / состояние не напоминает). */
public function current(EventState $state, User $user, Due $due, CarbonImmutable $now): ?Window
{
$activeStart = $user->activeStart($now);
$activeEnd = $user->activeEnd($now);
if ($now->lt($activeStart) || $now->gte($activeEnd)) {
return null;
}
return match ($state) {
EventState::Preparing, EventState::Overdue => new Window(
$activeStart, $activeEnd, true, $activeEnd->subMinutes($this->dayMarginMinutes),
),
EventState::Today => $this->hourly($due, $now, $activeEnd),
default => null,
};
}
private function hourly(Due $due, CarbonImmutable $now, CarbonImmutable $activeEnd): ?Window
{
$start = $now->startOfHour();
$end = $start->addHour()->min($activeEnd);
$truncated = false;
if ($due->moment->gt($start) && $due->moment->lt($end)) {
$end = $due->moment;
$truncated = true;
}
if ($now->gte($end)) {
return null;
}
$fallbackAllowed = $truncated || in_array((int) $start->format('G'), $this->fallbackHours, true);
return new Window($start, $end, $fallbackAllowed, $end->subMinutes($this->hourMarginMinutes));
}
}