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

Entity / Aggregate: AppointmentEntity

Дом полей агрегата «Запись» контекста calendar — договорённость инвестора и агента о встрече в конкретном слоте (availability-slot.md). Запись — отдельный агрегат, а не поле слота и не вид события (ADR-0064, п.4): у неё свой жизненный цикл (подтверждение агентом, отмена, исход встречи), который каскадно управляет слотом и порождённым календарным событием (calendar-event.md).

Назначение

AppointmentEntity создаётся, когда инвестор (или аноним через lazy-регистрацию по телефону — как в create-consultation-request) бронирует слот агента (../api/public/book-slot.md). Создание атомарно: переход слота Available → Booked + запись + календарное событие (Planned, Origin = Appointment). Дальше запись живёт своей статус-машиной; CRM узнаёт о ней только по событиям pin.calendar.appointment-* (get-or-create клиента/лида + лента лида — без синхронных записей чужих таблиц).

Классификация

ПолеЗначение
Контекстcalendar
KindAggregateRoot
Source of truthcalendar
Таблицаcalendar.appointments
Tenant isolationTenantId — компания агента; инвестор — вне tenant (ADR-0052), доступ к своим записям — record-level по InvestorUserId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPK; Guid v7appИдентификатор записи.
TenantIdGuidДаindex ix_appointments_tenant_idиз слотаКомпания агента.
SlotIdGuidДаFK fk_appointments_slot_id → calendar.availability_slots(id); unique partial ux_appointments_active_slot (SlotId) where status in ('Pending','Confirmed')appЗабронированный слот. Не более одной активной записи на слот (INV-APT-1).
AgentUserIdGuidДаindex ix_appointments_agent_start (AgentUserId, StartAt); unique partial ux_appointments_active_pair (InvestorUserId, AgentUserId, Kind) where status in ('Pending','Confirmed')снапшот из слотаАгент/консультант.
InvestorUserIdGuidДаindex partial ix_appointments_investor_active (InvestorUserId) where status in ('Pending','Confirmed')app (identity; lazy-регистрация анонима)Инвестор (пользователь identity, вне tenant).
KindEventKindДаenum (хранится как text), varchar(32); только Showing/Consultationснапшот из слотаВид встречи (подмножество EventKind).
StartAtDateTimeOffsetДаснапшот времени слотаНачало встречи, UTC; иммутабелен (INV-APT-5).
EndAtDateTimeOffsetДаcheck EndAt > StartAtснапшот времени слотаКонец встречи, UTC; иммутабелен.
TimeZonestringДаvarchar(64), IANAснапшот из слотаТаймзона слота на момент бронирования.
ProjectIdGuid?Нетснапшот из слотаПроект показа/консультации — логическая ссылка chessboard без валидации; иммутабельный снапшот значения слота на момент бронирования (INV-APT-5); попадает в payload appointment-created для ленты лида CRM.
PropertyObjectIdGuid?Нетснапшот из слотаОбъект недвижимости — логическая ссылка chessboard без валидации; иммутабельный снапшот значения слота на момент бронирования (INV-APT-5); попадает в payload appointment-created.
StatusAppointmentStatusДаenum (хранится как text), varchar(32), index ix_appointments_statusapp / workerСм. enum и полную матрицу переходов.
Commentstring?Нетvarchar(1024); класс данных Internal — маскирование в логахapp (инвестор при записи)Комментарий инвестора («интересует 2-комнатная…»); попадает в payload appointment-created — нужен ленте лида CRM.
PendingExpiresAtDateTimeOffset?Условноindex partial ix_appointments_pending_expires_at where status = 'Pending'app: min(CreatedAt + 24 ч, StartAt − MinLeadTimeMinutes слота)Дедлайн решения агента по Pending-записи (worker ../background/appointment-timeout.md); null при auto-confirm.
ConfirmedAtDateTimeOffset?НетappМомент подтверждения (→ Confirmed), в т.ч. auto-confirm.
CancelReasonstring?Условноvarchar(512); обязателен при Declined/CancelledappПричина отклонения/отмены.
CancelledByAppointmentCancelledBy?Условноenum (хранится как text), varchar(32); обязателен при Status = Cancelled (check)app / workerКто отменил (INV-APT-6).
RescheduledFromAppointmentIdGuid?НетFK selfappПредыдущая запись, из которой выполнен перенос (у новой записи).
RescheduledToAppointmentIdGuid?НетFK selfappНовая запись, в которую выполнен перенос (у отменённой записи). Ссылки пары взаимны, заполняются атомарно (INV-APT-5).
CalendarEventIdGuidДаunique ux_appointments_calendar_event_idappПорождённое календарное событие (Origin = Appointment, связь 1:1).
CompletedAtDateTimeOffset?НетappМомент фиксации исхода (→ Completed / → NoShow).
CreatedAtDateTimeOffsetДаindex ix_appointments_created_at descappСоздание записи (= бронирование слота).
UpdatedAtDateTimeOffsetДаapp / workerПоследнее изменение.
UpdatedByUserIdGuid?НетappПоследний изменивший (null — worker).
VersionintДаоптимистическая блокировкаappВерсия строки — арбитраж «агент ↔ инвестор ↔ worker».

Enum AppointmentStatus

ЗначениеОписание
Pending = 1Ожидает решения агента (слот с AutoConfirm = false); действует PendingExpiresAt.
Confirmed = 2Подтверждена (агентом или auto-confirm); встреча состоится.
Declined = 3Отклонена агентом на этапе Pending. Терминальный.
Cancelled = 4Отменена (инвестором до cutoff / агентом / системой при переносе). Терминальный.
Expired = 5Агент не принял решение до PendingExpiresAt (worker). Терминальный.
Completed = 6Встреча состоялась (исход зафиксирован агентом). Терминальный.
NoShow = 7Клиент не пришёл (исход зафиксирован агентом). Терминальный.

Активные статусы — Pending, Confirmed; остальные терминальны.

Enum AppointmentCancelledBy

ЗначениеОписание
Investor = 1Инвестор отменил/перенёс свою запись (в пределах cutoff).
Agent = 2Агент отменил подтверждённую или ожидающую запись.
System = 3Системная отмена платформой (административное вмешательство); переносы и таймауты сюда не относятся: перенос инвестором — Investor, таймаут Pending — отдельный статус Expired.

Связи

СвязьТипКлючDelete behavior
AvailabilitySlotEntityreference (запись занимает слот; параметры AutoConfirm/cutoff/lead-time читаются из слота-источника)SlotIdrestrict
CalendarEventEntityreference 1:1 (порождённое событие, Origin = Appointment)CalendarEventIdrestrict
AppointmentEntity (перенос)self-reference (цепочка переносов)RescheduledFromAppointmentId / RescheduledToAppointmentIdrestrict
Пользователи (identity)внешняя логическаяAgentUserId / InvestorUserId— (display-данные — из реплики identity)
Клиент/лид CRMвнешняя событийнаясобытия pin.calendar.appointment-* → get-or-create на стороне CRM— (календарь таблицы CRM не пишет)

Полная матрица переходов

ИзВКто / операцияУсловия (guards)
[*]Pendingинвестор/аноним — ../api/public/book-slot.mdслот Available, AutoConfirm = false; INV-SLT-3/5; INV-APT-1/2; атомарно со слотом и событием
[*]Confirmedинвестор/аноним — book-slotслот Available, AutoConfirm = true; те же guard-ы; ConfirmedAt = now
PendingConfirmedагент — ../api/agent/confirm-appointment.md (calendar.appointments.decide)до PendingExpiresAt
PendingDeclinedагент — ../api/agent/decline-appointment.mdCancelReason обязателен
PendingCancelledинвестор — ../api/investor/cancel-my-appointment.mdnow ≤ StartAt − CancellationCutoffMinutes (INV-APT-3); CancelledBy = Investor
PendingCancelledагент — ../api/agent/cancel-appointment.mdCancelledBy = Agent, CancelReason обязателен
PendingExpiredworker ../background/appointment-timeout.mdnow > PendingExpiresAt; идемпотентно
ConfirmedCancelledинвестор — cancel-my-appointment / переносnow ≤ StartAt − CancellationCutoffMinutes (INV-APT-3); CancelledBy = Investor (в т.ч. при переносе)
ConfirmedCancelledагент — cancel-appointmentдо фиксации исхода; CancelledBy = Agent, CancelReason обязателен
ConfirmedCompletedагент — ../api/agent/complete-appointment.md (Outcome = Completed)now ≥ StartAt (INV-APT-4)
ConfirmedNoShowагент — complete-appointment (Outcome = NoShow)now ≥ StartAt (INV-APT-4)

Любой другой переход → APPOINTMENT_INVALID_STATE. Перенос инвестором (../api/investor/reschedule-my-appointment.md) — не переход, а атомарная пара: отмена текущей записи (→ Cancelled) + создание новой на выбранный слот с взаимными ссылками RescheduledFrom/To (INV-APT-5); guard-ы обеих половин действуют одновременно (cutoff старой + доступность нового слота), при отказе любой половины не происходит ничего.

Каскады: переход записи → эффект на слот и календарное событие

Каскад выполняется в одном согласованном изменении с переходом записи — это единственный канал управления слотом в Booked (INV-SLT-4) и событием Origin = Appointment (INV-CAL-3). Правило освобождения слота: Booked → Available, если слот ещё пригоден для повторного бронирования (now ≤ StartAt − MinLeadTimeMinutes), иначе Booked → Expired.

Переход записиСлот (availability-slot.md)Календарное событие (calendar-event.md)Интеграционное событие
[*] → Pending (book-slot)Available → Bookedсоздаётся Planned (Origin = Appointment, время/вид/место из слота)appointment-created
[*] → Confirmed (auto-confirm)Available → Bookedсоздаётся Plannedappointment-created + appointment-confirmed
Pending → Confirmedостаётся Bookedостаётся Plannedappointment-confirmed
Pending → DeclinedBooked → Available / Expired→ Cancelled (CancelReason из записи)appointment-cancelled (Reason = DeclinedByAgent)
Pending → Cancelled (инвестор)Booked → Available / Expired→ Cancelledappointment-cancelled (Reason = CancelledByInvestor)
Pending → Cancelled (агент)Booked → Available / Expired→ Cancelledappointment-cancelled (Reason = CancelledByAgent)
Pending → Expired (worker)Booked → Available / Expired→ Cancelledappointment-cancelled (Reason = Expired)
Confirmed → Cancelled (инвестор/агент/перенос)Booked → Available / Expired→ Cancelledappointment-cancelled (Reason = CancelledByInvestor / CancelledByAgent)
Confirmed → CompletedBooked → Archived→ Completedappointment-completed (Outcome = Completed)
Confirmed → NoShowBooked → Archived→ Completed (факт встречи зафиксирован; исход — на записи)appointment-completed (Outcome = NoShow)

Инварианты

ИнвариантУсловиеКогда проверяетсяОшибка
INV-APT-1На слот — не более одной активной записи (Pending/Confirmed); создание записи атомарно с условным переходом слота Available → Booked и созданием календарного событиябронирование, перенос (unique partial ux_appointments_active_slot + INV-SLT-3)SLOT_ALREADY_BOOKED
INV-APT-2Антиспам: не более 1 активной записи на пару (InvestorUserId, AgentUserId, Kind) и не более 3 активных записей на инвестора суммарнобронирование, перенос (unique partial ux_appointments_active_pair + подсчёт по ix_appointments_investor_active)APPOINTMENT_DUPLICATE
INV-APT-3Cutoff инвестора: отмена/перенос инвестором — только пока now ≤ StartAt − CancellationCutoffMinutes (параметр слота); агент ограничением не связанcancel-my-appointment, reschedule-my-appointmentAPPOINTMENT_CUTOFF_PASSED
INV-APT-4Исход (Completed/NoShow) фиксирует только агент, только из Confirmed и только после наступления StartAtcomplete-appointmentAPPOINTMENT_NOT_STARTED / APPOINTMENT_INVALID_STATE
INV-APT-5Снапшот слота (StartAt/EndAt/TimeZone/Kind/ProjectId/PropertyObjectId) фиксируется при создании и неизменяем (последующее изменение слота-источника на запись не влияет); «перенос» = атомарная пара «отмена старой + новая запись на новый слот», ссылки RescheduledFrom/To взаимны и заполняются в одном согласованном изменениивсе операции; переносAPPOINTMENT_INVALID_STATE
INV-APT-6Терминальные статусы иммутабельны; каскады на слот и событие (таблица выше) выполняются в одном согласованном изменении с переходом записи; CancelledBy обязателен при Cancelled; Expired выставляет только workerвсе операции над записьюAPPOINTMENT_INVALID_STATE

Ошибки

КодHTTPУсловие
APPOINTMENT_NOT_FOUND404Запись не найдена или недоступна актору (record-level: инвестор видит только свои; агент — свои).
APPOINTMENT_ACCESS_DENIED403Актор — не участник записи и не имеет calendar.appointments.view/calendar.team.view в tenant.
APPOINTMENT_DUPLICATE409Нарушен антиспам INV-APT-2: уже есть активная запись на пару инвестор+агент+вид, либо у инвестора уже 3 активных записи.
APPOINTMENT_CUTOFF_PASSED409Отмена/перенос инвестором позже StartAt − CancellationCutoffMinutes (INV-APT-3).
APPOINTMENT_INVALID_STATE409Переход вне матрицы (в т.ч. операция над терминальной записью).
APPOINTMENT_NOT_STARTED409Фиксация исхода до наступления StartAt (INV-APT-4).
SLOT_ALREADY_BOOKED409Гонка за слот при создании/переносе — контракт слота (availability-slot.md).

Полные таблицы ошибок бронирования (включая lead-time, rate limit анонима, lazy-регистрацию) — в ../api/public/book-slot.md.

Доменные и интеграционные события

Ключ партиционирования — AppointmentId (порядок событий одной записи гарантирован). Payload — без PII; исключение — Comment инвестора в appointment-created (класс Internal, маскирование в логах): он нужен CRM для ленты лида.

СобытиеТипКогдаSnapshot/поля
AppointmentCreatedEventинтеграционное, topic pin.calendar.appointment-created.v1, ContractVersion = 1создание записи ([*] → Pending / → Confirmed)AppointmentId, TenantId, SlotId, AgentUserId, InvestorUserId, Kind, StartAt, EndAt, TimeZone, Status, Comment? (Internal), ProjectId?, PropertyObjectId? (иммутабельный снапшот слота из полей записи, не перечитывается из слота на момент публикации), CalendarEventId, RescheduledFromAppointmentId?; консюмеры: crm (get-or-create клиента/лида + лента), notifications (AppointmentCreated); контракт — ../events/appointment-created.md
AppointmentConfirmedEventинтеграционное, topic pin.calendar.appointment-confirmed.v1, ContractVersion = 1→ Confirmed (включая auto-confirm)AppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, TimeZone, ConfirmedAt, AutoConfirmed (bool); консюмеры: crm (лента лида), notifications (AppointmentConfirmed); контракт — ../events/appointment-confirmed.md
AppointmentCancelledEventинтеграционное, topic pin.calendar.appointment-cancelled.v1, ContractVersion = 1→ Declined / → Cancelled / → ExpiredAppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, TimeZone, Reason (DeclinedByAgent / CancelledByInvestor / CancelledByAgent / Expired), CancelledBy?, RescheduledToAppointmentId?; консюмеры: crm (лента лида), notifications (AppointmentCancelled); контракт — ../events/appointment-cancelled.md
AppointmentCompletedEventинтеграционное, topic pin.calendar.appointment-completed.v1, ContractVersion = 1→ Completed / → NoShowAppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, TimeZone, Outcome (Completed / NoShow), CompletedAt; консюмеры: crm (лента лида), notifications; контракт — ../events/appointment-completed.md

Интеграционные события публикуются атомарно со сменой состояния агрегата.

Межсервисные последовательности и промежуточные состояния

  • Самозапись с витрины. Аноним выбирает слот → lazy-регистрация по телефону (identity, как в create-consultation-request) → в одном согласованном изменении: слот Booked, запись Pending/Confirmed, событие Planned, материализованы напоминания, исходящее appointment-created. Далее асинхронно: crm делает get-or-create клиента/лида и пишет ленту; notifications уведомляет агента и инвестора.
  • Окно eventual-consistency. Запись в календаре агента видна сразу (данные calendar); лид/лента в CRM пополняются после обработки события (обычно < 1 мин, ADR-0064). Это штатно: источник истины записи — calendar, CRM — downstream-проекция воронки.
  • Двойной вход воронки. Слотовая самозапись и no-slot заявка (create-consultation-request) сосуществуют: первая — «выбираю конкретное время», вторая — «время обсудим»; обе сходятся в CRM в одном клиенте/лиде (get-or-create по телефону на стороне CRM-консюмера ../../crm/consumers/calendar-appointments.md).

Отказы, идемпотентность и конкуренция

  • Гонка за слот при создании/переносе — ровно один победитель (INV-SLT-3); проигравший получает SLOT_ALREADY_BOOKED, состояние записи/слота консистентно (либо всё, либо ничего).
  • Гонка «агент ↔ инвестор ↔ worker» (confirm vs cancel vs timeout) — арбитраж оптимистической блокировкой (Version): победитель фиксирует переход и каскад, проигравший перечитывает и получает APPOINTMENT_INVALID_STATE (или видит уже терминальный статус).
  • Идемпотентность worker-а appointment-timeout: повторный проход над уже не-Pending записью — no-op; ровно одно appointment-cancelled (Reason = Expired) на запись.
  • Повтор book-slot тем же инвестором на тот же слот/того же агента — отклоняется структурно (ux_appointments_active_slot, ux_appointments_active_pair): дублей записи не возникает.
  • Частичный отказ downstream (crm/notifications недоступны) — запись и календарь корректны; события доставляются at-least-once, консюмеры идемпотентны по AppointmentId — расхождение сходится повтором доставки.

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; ix_appointments_tenant_id; unique partial ux_appointments_active_slot (SlotId) where active; unique partial ux_appointments_active_pair (InvestorUserId, AgentUserId, Kind) where active; partial ix_appointments_investor_active (InvestorUserId) where active — лимит ≤ 3 и «мои записи»; ix_appointments_agent_start (AgentUserId, StartAt); ix_appointments_status; partial ix_appointments_pending_expires_at (PendingExpiresAt) where status = 'Pending' — worker; unique ux_appointments_calendar_event_id; ix_appointments_created_at desc
Партиционирование / retentionnone (ADR-0056)
Concurrencyоптимистическая блокировка (Version) + построчная блокировка на связке со слотом
Permissions / PIIагент: calendar.appointments.view / calendar.appointments.decide (confirm/decline/cancel/complete — только свои записи); руководитель: calendar.team.view (read-only, свой tenant); инвестор: record-level по InvestorUserId (../api/investor/list-my-appointments.md, cancel-my-appointment.md, reschedule-my-appointment.md) — без permission-модели tenant-а. Comment — класс Internal: маскирование в логах, не отдаётся в публичной витрине; телефона в записи нет (контакты — в identity)
AuditCreatedAt/ConfirmedAt/CompletedAt/UpdatedAt + UpdatedByUserId; CancelledBy/CancelReason — след отмены; цепочка переносов — RescheduledFrom/To; полная история — события pin.calendar.appointment-*

Acceptance-критерии

  • Самозапись (auto-confirm). Дано: слот Available с AutoConfirm = true, лимиты инвестора не исчерпаны. Когда инвестор бронирует. Тогда атомарно: слот Booked, запись Confirmed, событие Planned (Origin = Appointment), опубликованы appointment-created и appointment-confirmed.
  • Самозапись (ручное подтверждение). Дано: слот с AutoConfirm = false. Когда инвестор бронирует. Тогда запись Pending с PendingExpiresAt, агент получает уведомление; после confirm-appointmentConfirmed.
  • Дубль на пару. Дано: у инвестора активная запись Consultation к агенту A. Когда он бронирует второй слот Consultation того же агента. Тогда отказ APPOINTMENT_DUPLICATE.
  • Лимит 3. Дано: у инвестора 3 активных записи (к разным агентам). Когда он бронирует четвёртую. Тогда отказ APPOINTMENT_DUPLICATE.
  • Cutoff. Дано: Confirmed-запись, CancellationCutoffMinutes = 120, до StartAt 60 минут. Когда инвестор отменяет. Тогда отказ APPOINTMENT_CUTOFF_PASSED; агент при этом отменить может.
  • Timeout. Дано: Pending-запись, now > PendingExpiresAt. Когда проходит worker. Тогда запись Expired, слот освобождён (Available, либо Expired, если lead time уже прошёл), событие Cancelled, опубликовано appointment-cancelled (Reason = Expired); повторный проход — no-op.
  • Отклонение агентом. Дано: Pending-запись. Когда агент отклоняет с причиной. Тогда запись Declined, слот освобождён, событие Cancelled, опубликовано appointment-cancelled (Reason = DeclinedByAgent).
  • Исход раньше времени. Дано: Confirmed-запись, now < StartAt. Когда агент фиксирует NoShow. Тогда отказ APPOINTMENT_NOT_STARTED.
  • Исход. Дано: Confirmed-запись, now ≥ StartAt. Когда агент фиксирует Outcome = NoShow. Тогда запись NoShow, слот Archived, событие Completed, опубликовано appointment-completed (Outcome = NoShow).
  • Перенос. Дано: Confirmed-запись в пределах cutoff и свободный слот того же агента. Когда инвестор переносит. Тогда атомарно: старая запись Cancelled (RescheduledToAppointmentId заполнен), старый слот освобождён, новая запись создана на новый слот (RescheduledFromAppointmentId заполнен) со статусом по AutoConfirm нового слота; при занятости нового слота — SLOT_ALREADY_BOOKED, старая запись не тронута.

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

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

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

  • availability-slot.md — слот и гонка бронирования; calendar-event.md — порождённое событие (INV-CAL-3); event-reminder.md — напоминания о встрече.
  • ADR-0064 п.4 — запись как отдельный агрегат, событийная интеграция с CRM; ADR-0052 — инвестор вне tenant.
  • CRM-сторона: create-consultation-request (no-slot путь, lazy-регистрация, rate limit), consumer записей — ../../crm/consumers/calendar-appointments.md.
  • Контракты API: ../api/public/book-slot.md, ../api/agent/{confirm,decline,cancel,complete}-appointment.md, ../api/agent/list-appointments.md, ../api/investor/{list-my-appointments,get-my-appointment,cancel-my-appointment,reschedule-my-appointment}.md, ../api/agency/list-team-appointments.md, ../api/pin-manager/list-appointments.md.
  • Worker: ../background/appointment-timeout.md; события: ../events/appointment-{created,confirmed,cancelled,completed}.md.