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 отличается системно:
| Черновик Talent | PIN calendar | Комментарий |
|---|---|---|
| TPH-иерархия событий (наследники Task/Meeting) | единый агрегат CalendarEvent с Kind{Showing, Consultation, Meeting, Task, BookingDeadline} | плоская модель; вид события — enum, а не тип-наследник |
StartDate / EndDate | StartAt / EndAt (UTC) | + TimeZone (IANA) на событии, см. п.6 |
AssigneeUserId | OwnerUserId | владелец календаря (агент/консультант) |
Comment + Description (два поля) | Description (слиты в одно) | дублирующая семантика черновика устранена |
DisplayMode{Day,Week,Month} в API | range-параметры запроса (from/to) | представление день/неделя/месяц — забота клиента, не сервера |
WorkflowDealId | DealId | ссылка на сделку CRM |
| Синхронный internal-API для CRM | superseded — интеграция только событийная (п.4) | internal-API в PIN не переносится |
Обработчик «start» выставлял статус Cancelled (баг черновика) | start → InProgress | баг явно зафиксирован и исправлен в статус-машине 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-manager | internal-API календаря; изменение контракта create-consultation-request (только кросс-ссылки) |
| Данные | CalendarEvent, AvailabilitySlot, Appointment, reminder-строки, реплики deal-участников и display-данных пользователей | таблицы CRM (клиенты/лиды/брони) — календарь их не пишет |
| Эксплуатация | 3 polling-worker-а (reminder-dispatch 60 с, appointment-timeout, slot-expiration), метрики/алерты по образцу booking-expiration | Redis/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) | слот «придерживается» на время оформления записи с автоснятием по TTL | ADR-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.v1 | 1 | новые топики | additive |
pin.calendar.appointment-created/-confirmed/-cancelled/-completed.v1 | 1 | новые топики (payload без PII; комментарий инвестора — класс Internal, маскирование в логах) | additive |
pin.calendar.reminder-due.v1 | 1 | новый топик | additive |
Потребляемые: pin.crm.booking-*, pin.crm.deal-*, pin.identity.users.v1 | 1 | без изменений (новые консюмеры) | additive |
create-consultation-request (crm) | — | контракт не меняется (кросс-ссылки на слотовый путь) | — |
Permissions calendar.* в identity | — | +6 позиций | additive |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: AppointmentEntity, Entity / Aggregate: AvailabilitySlotEntity, Entity / Aggregate: CalendarEventEntity, Entity: EventReminderEntity
- API (9): GET /api/public/calendar/slots — Витрина свободных слотов самозаписи, GET /api/v1/agent/calendar/appointments — Список записей к агенту, GET /api/v1/agent/calendar/events — Список событий календаря агента, GET /api/v1/agent/calendar/slots — Список слотов доступности агента, POST /api/public/calendar/appointments — Самозапись на слот (бронирование), POST /api/v1/agent/calendar/events/{id}/start — Начало события (Planned → InProgress), POST /api/v1/agent/calendar/slots — Публикация слотов доступности (batch), POST /api/v1/agent/calendar/slots/archive — Архивация слотов (поштучно или по батчу), POST /api/v1/investor/calendar/appointments/{appointmentId}/reschedule — Перенос моей записи
- Use Cases (5): UC-CAL-001. Агент ведёт рабочий календарь, UC-CAL-002. Агент публикует слоты доступности, UC-CAL-009. Руководитель смотрит календарь команды, UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя, US-реестр контекста calendar (US-CAL)
- События (9): Event Contract: pin.calendar.appointment-cancelled (AppointmentCancelledEvent), Event Contract: pin.calendar.appointment-completed (AppointmentCompletedEvent), Event Contract: pin.calendar.appointment-confirmed (AppointmentConfirmedEvent), Event Contract: pin.calendar.appointment-created (AppointmentCreatedEvent), Event Contract: pin.calendar.event-created (CalendarEventCreatedEvent), Event Contract: pin.calendar.event-owner-changed (CalendarEventOwnerChangedEvent), Event Contract: pin.calendar.event-rescheduled (CalendarEventRescheduledEvent), Event Contract: pin.calendar.event-status-changed (CalendarEventStatusChangedEvent), Event Contract: pin.calendar.reminder-due (ReminderDueEvent)
- Консюмеры (4): Consumer: Calendar CRM Booking Sync (calendar-crm-booking-events), Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events), Consumer: Calendar Events → Notifications (notifications-calendar-events), Consumer: CRM Calendar Appointments Sync (crm-calendar-appointments)
- Фоновые задачи (3): Background Job: AppointmentTimeoutJob, Background Job: ReminderDispatchJob, Background Job: SlotExpirationJob
- scenarios (4): UC-OVR-001. Запрос консультации у агента недвижимости, UC-OVR-002. Запрос консультации у инвест-консультанта ПИН (+2a), UC-OVR-003. Бронирование объекта у агента (+3a), UC-OVR-011. Самозапись инвестора: от слота до исхода встречи и лида в воронке
- index.md (1): Calendar Service
Связанные документы
- План:
~/.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 — обоснование отложенной привязки слота к городу справочника).