diff --git a/core/app/Ingest/DueAtParser.php b/core/app/Ingest/DueAtParser.php new file mode 100644 index 0000000..af08256 --- /dev/null +++ b/core/app/Ingest/DueAtParser.php @@ -0,0 +1,57 @@ + 23 || $i > 59 || $s > 59) { + throw new InvalidArgumentException("due_at: невалидное время «{$raw}»"); + } + } +} diff --git a/core/app/Ingest/EventUpserter.php b/core/app/Ingest/EventUpserter.php new file mode 100644 index 0000000..464a732 --- /dev/null +++ b/core/app/Ingest/EventUpserter.php @@ -0,0 +1,118 @@ + $items + * @return list + */ + 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; + } +} diff --git a/core/app/Ingest/ParsedDue.php b/core/app/Ingest/ParsedDue.php new file mode 100644 index 0000000..721c6f3 --- /dev/null +++ b/core/app/Ingest/ParsedDue.php @@ -0,0 +1,60 @@ +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 колонки events */ + public function columns(): array + { + return [ + 'due_mode' => $this->mode, + 'due_date' => $this->date, + 'due_time' => $this->time, + 'due_instant' => $this->instant, + ]; + } +} diff --git a/core/tests/Feature/EventUpserterTest.php b/core/tests/Feature/EventUpserterTest.php new file mode 100644 index 0000000..14895bc --- /dev/null +++ b/core/tests/Feature/EventUpserterTest.php @@ -0,0 +1,137 @@ + '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')); + } +} diff --git a/core/tests/Unit/Ingest/DueAtParserTest.php b/core/tests/Unit/Ingest/DueAtParserTest.php new file mode 100644 index 0000000..335ce9c --- /dev/null +++ b/core/tests/Unit/Ingest/DueAtParserTest.php @@ -0,0 +1,55 @@ +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); + } + } + } +}