feat: due_at parser and event upserter

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:45:33 -03:00
parent c2fa8aa00d
commit 7d1df7828a
5 changed files with 427 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Ingest;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
final class DueAtParser
{
private const DATE = '/^(\d{4})-(\d{2})-(\d{2})$/';
private const LOCAL_DATETIME = '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/';
private const FIXED_DATETIME = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(Z|[+-]\d{2}:\d{2})$/';
public static function parse(string $raw): ParsedDue
{
if (preg_match(self::DATE, $raw, $m)) {
self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);
return ParsedDue::local($raw, DueResolver::WHOLE_DAY);
}
if (preg_match(self::LOCAL_DATETIME, $raw, $m)) {
self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);
self::assertTime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), $raw);
return ParsedDue::local(
sprintf('%s-%s-%s', $m[1], $m[2], $m[3]),
sprintf('%s:%s:%02d', $m[4], $m[5], (int) ($m[6] ?? 0)),
);
}
if (preg_match(self::FIXED_DATETIME, $raw)) {
try {
return ParsedDue::fixed(CarbonImmutable::parse($raw));
} catch (\Throwable $e) {
throw new InvalidArgumentException("due_at: невалидная дата-время «{$raw}»", 0, $e);
}
}
throw new InvalidArgumentException("due_at: ожидается YYYY-MM-DD, YYYY-MM-DDTHH:MM или дата-время со смещением, получено «{$raw}»");
}
private static function assertDate(int $y, int $m, int $d, string $raw): void
{
if (! checkdate($m, $d, $y)) {
throw new InvalidArgumentException("due_at: несуществующая дата «{$raw}»");
}
}
private static function assertTime(int $h, int $i, int $s, string $raw): void
{
if ($h > 23 || $i > 59 || $s > 59) {
throw new InvalidArgumentException("due_at: невалидное время «{$raw}»");
}
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Ingest;
use App\Enums\AfterDue;
use App\Enums\ChannelType;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use App\Scheduling\StateResolver;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
final class EventUpserter
{
public function __construct(private readonly StateResolver $states) {}
/**
* @param list<array{user:string,source_ref:string,topic?:string,due_at:string,fire_at:string,after_due:string,payload:array}> $items
* @return list<array{source_ref:string,topic:string,id:int,state:string}>
*/
public function upsert(Source $source, array $items): array
{
return DB::transaction(function () use ($source, $items) {
$out = [];
foreach ($items as $item) {
$event = $this->upsertOne($source, $item);
$out[] = [
'source_ref' => $event->source_ref,
'topic' => $event->topic,
'id' => $event->id,
'state' => $event->state->value,
];
}
return $out;
});
}
public function withdraw(Source $source, string $sourceRef, string $topic): bool
{
$event = $this->find($source, $sourceRef, $topic);
if ($event === null) {
return false;
}
$event->update(['state' => EventState::Withdrawn]);
return true;
}
public function find(Source $source, string $sourceRef, string $topic): ?Event
{
return Event::where('source_id', $source->id)
->where('source_ref', $sourceRef)
->where('topic', $topic)
->first();
}
/** Пользователь по логину; создаётся с поясом по умолчанию и web-каналом. */
public function ensureUser(string $login): User
{
$user = User::firstOrCreate(
['login' => $login],
['tz' => config('hado.default_tz'), 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00'],
);
$user->channels()->firstOrCreate(['type' => ChannelType::Web->value], ['config' => [], 'enabled' => true]);
return $user;
}
private function upsertOne(Source $source, array $item): Event
{
$user = $this->ensureUser($item['user']);
$due = DueAtParser::parse($item['due_at']);
$topic = $item['topic'] ?? '';
$now = $user->now();
$attributes = [
'user_id' => $user->id,
'fire_on' => $item['fire_at'],
'after_due' => AfterDue::from($item['after_due']),
'payload' => $item['payload'],
] + $due->columns();
$event = $this->find($source, $item['source_ref'], $topic);
if ($event === null) {
$event = new Event([
'source_id' => $source->id,
'source_ref' => $item['source_ref'],
'topic' => $topic,
'state' => EventState::Scheduled,
'quiet_until' => $now->subMinute(),
] + $attributes);
$event->state = $this->states->resolve($event, $user, $now);
$event->save();
return $event;
}
$dueChanged = ! $due->equals($event);
$event->fill($attributes);
if ($dueChanged) {
// Перенос: новая дата — новый заход, даже из терминального состояния.
$event->done_at = null;
$event->quiet_until = $now->subMinute();
$event->state = $this->states->resolve($event, $user, $now);
} elseif (! $event->isTerminal()) {
$event->state = $this->states->resolve($event, $user, $now);
}
$event->save();
return $event;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Ingest;
use App\Enums\DueMode;
use App\Models\Event;
use Carbon\CarbonImmutable;
/** Разобранный due_at: tagged union по mode. */
final class ParsedDue
{
private function __construct(
public readonly DueMode $mode,
public readonly ?string $date,
public readonly ?string $time,
public readonly ?CarbonImmutable $instant,
) {}
public static function local(string $date, string $time): self
{
return new self(DueMode::Local, $date, $time, null);
}
public static function fixed(CarbonImmutable $instant): self
{
return new self(DueMode::Fixed, null, null, $instant->utc());
}
/** Календарный день срока в данном поясе, Y-m-d. */
public function dayFor(string $tz): string
{
return match ($this->mode) {
DueMode::Local => $this->date,
DueMode::Fixed => $this->instant->setTimezone($tz)->format('Y-m-d'),
};
}
public function equals(Event $event): bool
{
if ($event->due_mode !== $this->mode) {
return false;
}
return match ($this->mode) {
DueMode::Local => $event->due_date->format('Y-m-d') === $this->date && $event->due_time === $this->time,
DueMode::Fixed => $event->due_instant->eq($this->instant),
};
}
/** @return array<string, mixed> колонки events */
public function columns(): array
{
return [
'due_mode' => $this->mode,
'due_date' => $this->date,
'due_time' => $this->time,
'due_instant' => $this->instant,
];
}
}