Перейти к основному содержимому

ADR-0064 — контекст calendar (календарь и запись на показ/консультацию)

Статус

Accepted

Дата

2026-07-15

Контекст

В PIN нет скедулинга вообще: единственный намёк на время встречи — мягкий необязательный хинт PreferredAt в публичной заявке на консультацию (create-consultation-request.md), который агент интерпретирует вручную. При этом бизнес-процессы требуют полноценного календаря:

  • агенту нужен рабочий календарь: показы, консультации, встречи, задачи и дедлайны броней (резерв/бронь из CRM истекают по таймерам — агенту нужно видеть их как события, а не ловить по уведомлениям);
  • инвестору нужна самозапись с витрины: выбрать свободный слот агента на показ/консультацию вместо «оставьте телефон, вам перезвонят»;
  • руководителю агентства — календарь команды; консультанту ПИН — своя витрина записи (UC-OVR-002);
  • напоминания о встречах должны уходить через notifications без ручного контроля.

Есть черновик календаря в экосистеме Talent (F:\Workspace\Talent\microservices.crm.calendar): TPH-иерархия событий (Task/Meeting), поля StartDate/EndDate/AssigneeUserId/Comment/WorkflowDealId, режимы отображения DisplayMode{Day,Week,Month} в API, синхронный internal-API для CRM. Черновик полезен как исходник доменных идей, но не переносим as-is: модель заточена под задачи CRM-воронки, не знает самозаписи/слотов/напоминаний, а его обработчик «начать событие» содержит баг — выставляет статус Cancelled вместо «в работе».

Ограничения платформы: simplicity-first — PostgreSQL как основная СУБД, без Redis, фоновые процессы — polling + построчная блокировка (ADR-0056, образец — booking-expiration.md); границы контекстов — сервис чужие таблицы не пишет, интеграция через Kafka-события (service-boundaries, образец контракта — booking-created.md); tenancy — ADR-0052 (инвестор вне tenant, календарь агента — в tenant агентства).

Решение

1. Отдельный bounded context calendar, а не модуль CRM

  • Вводится самостоятельный контекст calendar (docs/06-services/calendar/) со своей схемой БД, своими API-областями и своими Kafka-топиками pin.calendar.*.
  • Основание границы: у календаря собственный набор акторов — агент (владелец календаря), инвестор (записывается и управляет своими записями), руководитель агентства (календарь команды), менеджер ПИН, аноним с витрины (просмотр свободных слотов и самозапись через lazy-регистрацию) — и собственный жизненный цикл, не совпадающий с воронкой лидов.
  • CRM остаётся владельцем лид-интейка: календарь не создаёт клиентов/лидов сам. CRM консюмит события pin.calendar.appointment-created/confirmed/cancelled/completed и по ним делает get-or-create клиента/лида и записи в ленту лида (новые значения LeadEventType). Обратной синхронной связи нет.

2. Переиспользование черновика Talent — с переработкой (маппинг-таблица)

Черновик F:\Workspace\Talent\microservices.crm.calendar используется как исходник доменных идей; модель PIN отличается системно:

Черновик TalentPIN calendarКомментарий
TPH-иерархия событий (наследники Task/Meeting)единый агрегат CalendarEvent с Kind{Showing, Consultation, Meeting, Task, BookingDeadline}плоская модель; вид события — enum, а не тип-наследник
StartDate / EndDateStartAt / EndAt (UTC)+ TimeZone (IANA) на событии, см. п.6
AssigneeUserIdOwnerUserIdвладелец календаря (агент/консультант)
Comment + Description (два поля)Description (слиты в одно)дублирующая семантика черновика устранена
DisplayMode{Day,Week,Month} в APIrange-параметры запроса (from/to)представление день/неделя/месяц — забота клиента, не сервера
WorkflowDealIdDealIdссылка на сделку CRM
Синхронный internal-API для CRMsuperseded — интеграция только событийная (п.4)internal-API в PIN не переносится
Обработчик «start» выставлял статус Cancelled (баг черновика)startInProgressбаг явно зафиксирован и исправлен в статус-машине PIN

Статус-машина события PIN: Status{Planned = 1, InProgress = 2, Completed = 3, Cancelled = 4}; происхождение — Origin{Manual, Appointment, CrmBooking} (ручное событие агента / порождено записью / порождено booking-событием CRM); события с Origin = Appointment/CrmBooking управляются только своим источником, прямое редактирование запрещено.

3. Слоты самозаписи — материализованные строки, без free/busy и без hold'ов

  • Доступность агента представляется материализованными строками AvailabilitySlot: агент публикует batch (локальные окна доступности × дни недели × длительность слота → конкретные строки-слоты на горизонт, DST-корректно — генерация в локальном времени агента, хранение в UTC). Статусы слота: Available / Booked / Expired / Archived; параметры: Kind{Showing, Consultation}, AutoConfirm, CancellationCutoffMinutes (default 120), минимальный lead time 60 мин, batch ≤ 500 слотов.
  • НЕ computed free/busy: свободные интервалы не вычисляются на лету из занятости календаря.
  • НЕ временные hold'ы: слот не «придерживается» на время оформления (ни TTL-строк, ни Redis).
  • Гонка бронирования решается условным переходом Available → Booked под построчной блокировкой: выигрывает ровно один запрос, проигравший получает 409 SLOT_ALREADY_BOOKED и выбирает другой слот. Это единственная точка конкуренции — простая, наблюдаемая и идемпотентная по состоянию.

4. Запись (Appointment) — отдельный агрегат; интеграция с CRM только событиями

  • Appointment — отдельный агрегат (не поле слота и не вид события): Status{Pending, Confirmed, Declined, Cancelled, Expired, Completed, NoShow}, снапшот времени слота, CancelledBy{Investor, Agent, System}, ссылки на слот и порождённое календарное событие. Запись создаётся атомарно с переходом слота Available → Booked и созданием CalendarEvent (Planned, Origin = Appointment); при AutoConfirm слота — сразу Confirmed, иначе агент подтверждает/отклоняет.
  • Переходы записи каскадно управляют слотом и событием (decline/cancel/expire освобождают или архивируют слот, отменяют событие); просроченные Pending истекают фоновым worker-ом (Pending → Expired).
  • Интеграция с CRM — только событиями pin.calendar.appointment-*: календарь не пишет таблицы CRM, CRM не пишет таблицы календаря. Календарь, в свою очередь, консюмит pin.crm.booking-* (авто-события BookingDeadline из дедлайнов резерва/брони) и pin.crm.deal-* (реплика участников сделки).
  • Публичная заявка create-consultation-request с мягким PreferredAt сохраняется как no-slot путь («хочу консультацию, время обсудим»): оба входа сосуществуют и взаимно ссылаются; слотовая самозапись — для случая «выбираю конкретное время».

5. Напоминания — материализованные строки + polling; recurrence вне scope

  • Напоминания — материализованные reminder-строки, порождаемые из офсетов события (ReminderOffsetsMinutes, default [1440, 60] — за сутки и за час); при переносе события строки пересоздаются. Статусы: Pending / Sent / Skipped.
  • Диспетчеризация — polling-worker (цикл 60 с, выборка DueAt < now под SKIP LOCKED — тот же паттерн, что booking-expiration); по факту — событие pin.calendar.reminder-due → канал доставки выбирает notifications.
  • Recurrence (повторяющиеся события) — вне scope: повторяющуюся доступность полностью закрывает batch-генерация слотов (п.3), а повторяющиеся встречи агенту в v1 не нужны. Триггер пересмотра — подтверждённый спрос на повторяющиеся встречи; до этого RRULE-механика не вводится.

6. Таймзоны

  • Хранение всех моментов — UTC; на календарном событии и на слот-батче дополнительно хранится IANA TimeZone (строка, например Europe/Moscow) — таймзона, в которой событие/окна имеют смысл.
  • Генерация слотов выполняется в локальном времени агента (окно «10:00–13:00 по вторникам» остаётся 10:00 локальными при переходе на летнее/зимнее время; UTC-моменты слотов по обе стороны перевода часов различаются — это корректно, не дефект).
  • Все API принимают и отдают UTC ISO-8601; локальное отображение — забота клиента по TimeZone.

7. Вне scope v1 (осознанные исключения из общих правил платформы)

Ниже — не забытые связи, а сознательно принятые исключения: каждое зафиксировано с обоснованием и триггером пересмотра, чтобы не выглядеть молчаливым расхождением со справочником handbook и с моделью сделок CRM.

  • Internal-API календаря (синхронные вызовы из других контекстов) — не вводится, интеграция событийная.
  • Ручное переназначение события/записи другому агенту пользователем — не вводится (отмена + новая запись). Исключение (B6.3): автоматическое переназначение владельца открытых deal-привязанных событий (дедлайны брони + встречи) на нового исполнителя при смене исполнителя сделки (deal-members-changed ExecutorChanged) или уходе агента/увольнении — в scope, выполняется консюмером deal-событий CRM (INV-CAL-8, docs/06-services/calendar/consumers/crm-deal-events.md), публикует pin.calendar.event-owner-changed.v1. События Origin = Appointment этим не затрагиваются.
  • Групповые показы — не вводятся: capacity = 1 (один слот — один инвестор).
  • Слот/событие календаря НЕ привязаны к городу справочника handbook (осознанное исключение). В v1 место встречи — свободный текст Location + опциональные ProjectId/PropertyObjectId; поля CityId (или иной ссылки на регион/город справочника) на CalendarEvent/AvailabilitySlot нет. Как следствие, гео-фильтрация витрины слотов (например «показать слоты в моём городе») в v1 вне scope — витрина фильтрует по агенту/проекту/виду, но не по городу справочника. Обоснование: встреча привязана к человеку (агент/консультант) и объекту, а не к географической единице справочника; типизированная привязка к handbook без потребителя (гео-фильтра) была бы преждевременной. Триггер пересмотра — подтверждённый спрос на гео-фильтры витрины по слотам: тогда вводится типизированная ссылка на город справочника (additive-поле), а не свободный текст.
  • Обратная корреляция «событие календаря ↔ конкретная встреча сделки CRM» — не вводится (осознанное исключение). На CalendarEvent есть только DealId (ссылка на сделку целиком); отдельного корреляционного поля вроде DealMeetingEventId (уникальная привязка календарного события к конкретной встрече/шагу воронки внутри сделки CRM) в v1 нет. Интеграция сделка ↔ календарь идёт только через Kafka по DealId: CRM консюмит pin.calendar.appointment-*/event-* и сама решает, к какому шагу своей воронки отнести событие; календарь консюмит pin.crm.deal-* (реплика участников). Обратной синхронной связки и сквозного per-meeting-идентификатора между контекстами нет. Обоснование: сквозной корреляционный id связал бы внутреннюю модель воронки CRM с моделью календаря и потребовал бы двусторонней синхронной консистентности вопреки событийной границе (п.4). Триггер пересмотра — реальная потребность CRM однозначно сопоставлять календарное событие конкретной встрече сделки: тогда вводится additive корреляционный id (например DealMeetingId), проносимый в payload событий pin.calendar.*, без слома контрактов и без синхронного internal-API.

Область Действия

ОбластьВходитНе входит
Сервисыновый calendar; правки crm (consumer записей, LeadEventType), notifications (consumer + новые NotificationKind), identity (6 permissions calendar.*)chessboard, catalog, messenger, rewards
Контрактытопики pin.calendar.*.v1 (event-created/-rescheduled/-status-changed, appointment-created/-confirmed/-cancelled/-completed, reminder-due); API-области public/investor/agent/agency/pin-managerinternal-API календаря; изменение контракта create-consultation-request (только кросс-ссылки)
ДанныеCalendarEvent, AvailabilitySlot, Appointment, reminder-строки, реплики deal-участников и display-данных пользователейтаблицы CRM (клиенты/лиды/брони) — календарь их не пишет
Эксплуатация3 polling-worker-а (reminder-dispatch 60 с, appointment-timeout, slot-expiration), метрики/алерты по образцу booking-expirationRedis/TTL/внешние планировщики

Последствия

КонтекстПоследствие
Платформа19-й бизнес-контекст (в общей нумерации плана — один из трёх новых, итого 21); +8 топиков pin.calendar.*; +6 permissions (calendar.events.view/manage, calendar.slots.manage, calendar.appointments.view/decide, calendar.team.view).
crmновый consumer pin.calendar.appointment-* → get-or-create клиента/лида + события ленты; create-consultation-request и book-slot описываются как два сосуществующих входа воронки. Лента лида пополняется асинхронно (eventual consistency) — агент видит запись в календаре сразу, в лиде — после обработки события.
notificationsновые NotificationKind (создание/подтверждение/отмена записи, напоминания, перенос события) + consumer всех pin.calendar.*.
Черновик Talentпереносится идейно, не кодом: TPH и internal-API отброшены, дублирующие поля слиты, баг «start → Cancelled» исправлен на «start → InProgress»; ревизия каждого переносимого правила обязательна.
Эксплуатацияматериализованные слоты — рост числа строк (агент × окна × горизонт); компенсируется лимитом batch ≤ 500, воркером slot-expiration (архив прошедших) и целевой нагрузкой ≤ 20k агентов (ADR-0056).
handbook (осознанное исключение)CalendarEvent/AvailabilitySlot не ссылаются на город/регион справочника: место встречи — свободный Location + опциональные ProjectId/PropertyObjectId. Следствие — гео-фильтрация витрины слотов по городу справочника вне scope v1. Это сознательное исключение, а не пропуск связи; при спросе на гео-фильтр вводится additive-ссылка на город справочника.
crm (осознанное исключение)между сделкой и календарём нет сквозного per-meeting-идентификатора: на событии только DealId, поля DealMeetingEventId нет, обратной корреляции «событие календаря ↔ конкретная встреча сделки» не вводим. Интеграция — только событийная по DealId (п.4). Это сознательное исключение ради событийной границы; при реальной потребности CRM сопоставлять событие конкретной встрече вводится additive корреляционный id в payload pin.calendar.* без слома контрактов.

Альтернативы (рассмотрено и отклонено)

ВариантОписаниеПричина отклонения
Календарь как модуль внутри crmтаблицы/API календаря в контексте crmу календаря свои акторы (включая анонимов витрины и инвесторов вне tenant) и свой жизненный цикл; модуль раздул бы CRM и связал самозапись витрины с внутренней воронкой; лид-интейк остался бы перемешан с расписанием. CRM остаётся владельцем лидов, календарь — расписания.
Computed free/busyсвободные интервалы вычисляются на лету из занятости календаря агентасложная конфликт-детекция (пересечения событий, буферы, окна, DST) на каждый запрос витрины; трудно кэшировать и объяснять; противоречит simplicity-first (ADR-0056). Материализованный слот — одна строка с явным статусом и тривиальной гонкой.
Временные hold'ы слота (Redis/TTL)слот «придерживается» на время оформления записи с автоснятием по TTLADR-0056 прямо исключает Redis в подобных сценариях; hold добавляет второе хранилище состояния и пограничные случаи (протухший hold, потерянный TTL). Условный переход Available → Booked решает гонку без hold'ов; «передумал» = отмена записи.
Перенос черновика Talent as-isскопировать TPH-модель, internal-API и контракты черновикачерновик не знает слотов/записей/напоминаний, содержит подтверждённый баг статус-машины и синхронную связку с CRM, противоречащую событийной интеграции PIN. Переносим идеи через маппинг-таблицу (п.2), не код.

Риски

РискМитигция
DST-ошибки генерации слотов (двойные/пропавшие слоты на переводе часов)генерация строго в локальном времени агента по IANA TimeZone батча, хранение UTC; acceptance-тесты на даты перевода часов
Разрастание таблицы слотовbatch ≤ 500, lead time и горизонт публикации, worker slot-expiration (Expired/Archived), архив не участвует в выборках витрины
Зависшие Pending-записи держат слотыworker appointment-timeout: Pending → Expired + освобождение слота (паттерн booking-expiration: polling 60 с, SKIP LOCKED, идемпотентность по состоянию)
Спам самозаписи (аноним/lazy-регистрация)rate limit по IP+телефону (как в create-consultation-request); антиспам-инварианты: 1 активная запись на пару инвестор+агент+вид, ≤ 3 активных записей на инвестора
Расхождение календарь ↔ CRM (событийная интеграция)идемпотентные консюмеры (business-ключи), DLQ + алерты; окно eventual consistency явно описано в UC; синхронных записей чужих таблиц нет — расхождение сходится повтором доставки
Скрытые дефекты черновика (по образцу «start → Cancelled»)черновик — только источник идей; каждое правило проходит ревизию через маппинг-таблицу п.2; статус-машины фиксируются инвариантами в доменных доках PIN
Двойное бронирование слота при конкуренцииединственная точка истины — условный переход Available → Booked под построчной блокировкой; проигравший получает детерминированный 409 SLOT_ALREADY_BOOKED

Затронутые контракты и совместимость

КонтрактВерсияСовместимостьМиграция
pin.calendar.event-created/-rescheduled/-status-changed.v11новые топикиadditive
pin.calendar.appointment-created/-confirmed/-cancelled/-completed.v11новые топики (payload без PII; комментарий инвестора — класс Internal, маскирование в логах)additive
pin.calendar.reminder-due.v11новый топикadditive
Потребляемые: pin.crm.booking-*, pin.crm.deal-*, pin.identity.users.v11без изменений (новые консюмеры)additive
create-consultation-request (crm)контракт не меняется (кросс-ссылки на слотовый путь)
Permissions calendar.* в identity+6 позицийadditive

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • План: ~/.claude/plans/happy-cooking-thunder.md, Workstream 2 (доменная модель, реестр из ~55 доков docs/06-services/calendar/).
  • Эталоны паттернов: create-consultation-request.md (lazy-регистрация, rate limit, no-slot путь), booking-expiration.md (polling + SKIP LOCKED), booking-created.md (контракт события, источник дедлайнов BookingDeadline).
  • Сценарии: docs/02-architecture/scenarios/UC-OVR-001/002/003 (альтернативный поток «инвестор выбирает слот», дедлайны брони в календаре).
  • ADR: 0056 simplicity-first (PostgreSQL-only, без Redis, polling-worker-ы), 0052 tenancy (инвестор вне tenant, system-scope worker-ов), ADR-0063 (handbook — обоснование отложенной привязки слота к городу справочника).