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:
57
core/app/Ingest/DueAtParser.php
Normal file
57
core/app/Ingest/DueAtParser.php
Normal 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}»");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
core/app/Ingest/EventUpserter.php
Normal file
118
core/app/Ingest/EventUpserter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
core/app/Ingest/ParsedDue.php
Normal file
60
core/app/Ingest/ParsedDue.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
137
core/tests/Feature/EventUpserterTest.php
Normal file
137
core/tests/Feature/EventUpserterTest.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\ChannelType;
|
||||||
|
use App\Enums\EventState;
|
||||||
|
use App\Ingest\EventUpserter;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Source;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EventUpserterTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Source $source;
|
||||||
|
private EventUpserter $upserter;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
config(['hado.default_tz' => 'America/Montevideo']);
|
||||||
|
CarbonImmutable::setTestNow('2026-09-01T12:00:00-03:00');
|
||||||
|
$this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||||||
|
$this->upserter = app(EventUpserter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function item(array $over = []): array
|
||||||
|
{
|
||||||
|
return array_replace([
|
||||||
|
'user' => 'nikita', 'source_ref' => 'document:918', 'topic' => 'expiry',
|
||||||
|
'due_at' => '2026-09-30', 'fire_at' => '2026-08-31', 'after_due' => 'keep',
|
||||||
|
'payload' => ['title' => 'Седула протухает 30 сен'],
|
||||||
|
], $over);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_event_user_and_web_channel_with_computed_state(): void
|
||||||
|
{
|
||||||
|
$result = $this->upserter->upsert($this->source, [$this->item()]);
|
||||||
|
|
||||||
|
$this->assertSame([['source_ref' => 'document:918', 'topic' => 'expiry', 'id' => 1, 'state' => 'preparing']], $result);
|
||||||
|
$user = User::where('login', 'nikita')->firstOrFail();
|
||||||
|
$this->assertSame('America/Montevideo', $user->tz);
|
||||||
|
$this->assertSame(ChannelType::Web, $user->channels()->sole()->type);
|
||||||
|
$event = Event::sole();
|
||||||
|
$this->assertSame(EventState::Preparing, $event->state);
|
||||||
|
$this->assertTrue($event->quiet_until->lt(CarbonImmutable::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_same_due_updates_fields_and_keeps_terminal_state(): void
|
||||||
|
{
|
||||||
|
$this->upserter->upsert($this->source, [$this->item()]);
|
||||||
|
Event::sole()->update(['state' => EventState::Done, 'done_at' => now()]);
|
||||||
|
|
||||||
|
$this->upserter->upsert($this->source, [$this->item(['payload' => ['title' => 'Новый заголовок'], 'fire_at' => '2026-09-15'])]);
|
||||||
|
|
||||||
|
$event = Event::sole();
|
||||||
|
$this->assertSame(EventState::Done, $event->state);
|
||||||
|
$this->assertSame('Новый заголовок', $event->payload['title']);
|
||||||
|
$this->assertSame('2026-09-15', $event->fire_on->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_same_due_recomputes_non_terminal_state(): void
|
||||||
|
{
|
||||||
|
$this->upserter->upsert($this->source, [$this->item()]);
|
||||||
|
$this->assertSame(EventState::Preparing, Event::sole()->state);
|
||||||
|
|
||||||
|
$this->upserter->upsert($this->source, [$this->item(['fire_at' => '2026-09-20'])]);
|
||||||
|
$this->assertSame(EventState::Scheduled, Event::sole()->state, 'старт отодвинули в будущее');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_new_due_resets_even_from_terminal(): void
|
||||||
|
{
|
||||||
|
$this->upserter->upsert($this->source, [$this->item()]);
|
||||||
|
Event::sole()->update(['state' => EventState::Done, 'done_at' => now(), 'quiet_until' => CarbonImmutable::now()->addDays(3)]);
|
||||||
|
|
||||||
|
$this->upserter->upsert($this->source, [$this->item(['due_at' => '2026-10-15'])]);
|
||||||
|
|
||||||
|
$event = Event::sole();
|
||||||
|
$this->assertSame(EventState::Preparing, $event->state);
|
||||||
|
$this->assertNull($event->done_at);
|
||||||
|
$this->assertTrue($event->quiet_until->lt(CarbonImmutable::now()));
|
||||||
|
$this->assertSame('2026-10-15', $event->due_date->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reschedule_on_the_day_resets_today_event(): void
|
||||||
|
{
|
||||||
|
// Задокументированное поведение: перенос в день срока сбрасывает состояние.
|
||||||
|
// Поэтому повторяющиеся события кладут период в topic, а не меняют due_at.
|
||||||
|
CarbonImmutable::setTestNow('2026-09-30T10:00:00-03:00');
|
||||||
|
$this->upserter->upsert($this->source, [$this->item()]);
|
||||||
|
$this->assertSame(EventState::Today, Event::sole()->state);
|
||||||
|
|
||||||
|
$this->upserter->upsert($this->source, [$this->item(['due_at' => '2027-09-30', 'fire_at' => '2027-08-31'])]);
|
||||||
|
$this->assertSame(EventState::Scheduled, Event::sole()->state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_occurrences_are_independent_rows(): void
|
||||||
|
{
|
||||||
|
CarbonImmutable::setTestNow('2026-10-05T10:00:00-03:00');
|
||||||
|
$this->upserter->upsert($this->source, [
|
||||||
|
$this->item(['source_ref' => 'rent', 'topic' => 'payment:2026-09', 'due_at' => '2026-09-10', 'fire_at' => '2026-09-10']),
|
||||||
|
$this->item(['source_ref' => 'rent', 'topic' => 'payment:2026-10', 'due_at' => '2026-10-10', 'fire_at' => '2026-10-10']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$states = Event::orderBy('topic')->pluck('state')->map->value->all();
|
||||||
|
$this->assertSame(['overdue', 'scheduled'], $states);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fixed_due_is_stored_as_instant(): void
|
||||||
|
{
|
||||||
|
$this->upserter->upsert($this->source, [$this->item(['due_at' => '2026-09-10T08:15+02:00', 'fire_at' => '2026-09-03'])]);
|
||||||
|
|
||||||
|
$event = Event::sole();
|
||||||
|
$this->assertSame('fixed', $event->due_mode->value);
|
||||||
|
$this->assertSame('2026-09-10T06:15:00+00:00', $event->due_instant->utc()->toIso8601String());
|
||||||
|
$this->assertNull($event->due_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_withdraw_marks_withdrawn_and_reports_missing(): void
|
||||||
|
{
|
||||||
|
$this->upserter->upsert($this->source, [$this->item()]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->upserter->withdraw($this->source, 'document:918', 'expiry'));
|
||||||
|
$this->assertSame(EventState::Withdrawn, Event::sole()->state);
|
||||||
|
$this->assertFalse($this->upserter->withdraw($this->source, 'document:918', 'other'));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
core/tests/Unit/Ingest/DueAtParserTest.php
Normal file
55
core/tests/Unit/Ingest/DueAtParserTest.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Ingest;
|
||||||
|
|
||||||
|
use App\Enums\DueMode;
|
||||||
|
use App\Ingest\DueAtParser;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
|
||||||
|
|
||||||
|
class DueAtParserTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_date_only_is_whole_day_local(): void
|
||||||
|
{
|
||||||
|
$p = DueAtParser::parse('2026-08-14');
|
||||||
|
$this->assertSame(DueMode::Local, $p->mode);
|
||||||
|
$this->assertSame('2026-08-14', $p->date);
|
||||||
|
$this->assertSame('24:00:00', $p->time);
|
||||||
|
$this->assertNull($p->instant);
|
||||||
|
$this->assertSame('2026-08-14', $p->dayFor('Asia/Shanghai'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_datetime_without_offset_is_local(): void
|
||||||
|
{
|
||||||
|
$p = DueAtParser::parse('2026-09-10T14:00');
|
||||||
|
$this->assertSame(DueMode::Local, $p->mode);
|
||||||
|
$this->assertSame('2026-09-10', $p->date);
|
||||||
|
$this->assertSame('14:00:00', $p->time);
|
||||||
|
|
||||||
|
$this->assertSame('14:05:30', DueAtParser::parse('2026-09-10T14:05:30')->time);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_datetime_with_offset_is_fixed(): void
|
||||||
|
{
|
||||||
|
$p = DueAtParser::parse('2026-09-10T08:15+02:00');
|
||||||
|
$this->assertSame(DueMode::Fixed, $p->mode);
|
||||||
|
$this->assertNull($p->date);
|
||||||
|
$this->assertSame('2026-09-10T06:15:00+00:00', $p->instant->utc()->toIso8601String());
|
||||||
|
$this->assertSame('2026-09-10', $p->dayFor('America/Montevideo'));
|
||||||
|
$this->assertSame('2026-09-10', $p->dayFor('Asia/Shanghai'));
|
||||||
|
|
||||||
|
$this->assertSame(DueMode::Fixed, DueAtParser::parse('2026-09-10T06:15:00Z')->mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_garbage_is_rejected(): void
|
||||||
|
{
|
||||||
|
foreach (['', 'tomorrow', '2026-13-01', '14:00', '2026-09-10 14:00', '2026-09-10T25:00'] as $bad) {
|
||||||
|
try {
|
||||||
|
DueAtParser::parse($bad);
|
||||||
|
$this->fail("accepted: $bad");
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user