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

Entity / Aggregate: AvailabilitySlotEntity

Дом полей агрегата «Слот доступности» контекста calendar — материализованная строка «агент свободен для показа/консультации в конкретный интервал». Слоты публикуются батчами и бронируются инвестором с витрины (самозапись). Решение — материализованные строки без computed free/busy и без временных hold'ов (ADR-0064, п.3).

Назначение

AvailabilitySlotEntity — единица предложения времени агента (или консультанта ПИН) на витрине: аноним или инвестор видит свободные слоты (../api/public/list-available-slots.md) и записывается на конкретный (../api/public/book-slot.md). Занятие слота атомарно создаёт запись (appointment.md) и календарное событие (calendar-event.md). Гонка за слот решается условным переходом Available → Booked: выигрывает ровно один запрос, проигравший получает 409 SLOT_ALREADY_BOOKED и выбирает другой слот.

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

ПолеЗначение
Контекстcalendar
KindAggregateRoot
Source of truthcalendar
Таблицаcalendar.availability_slots
Tenant isolationTenantId — компания агента (агентство / ПИН), ADR-0052; публичная витрина слотов — анонимный read-only без tenant-контекста

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPK; Guid v7appИдентификатор слота.
TenantIdGuidДаindex ix_availability_slots_tenant_idиз агентаКомпания агента.
AgentUserIdGuidДаindex ix_availability_slots_agent_start (AgentUserId, StartAt)appАгент/консультант — владелец слота.
KindEventKindДаenum (хранится как text), varchar(32); только Showing/ConsultationappВид встречи, на которую открыт слот (подмножество EventKind).
StatusSlotStatusДаenum (хранится как text), varchar(32), index ix_availability_slots_status_start (Status, StartAt)app / worker / каскад записиСм. enum и stateDiagram.
StartAtDateTimeOffsetДаcheck StartAt < EndAtгенерация батчаНачало слота, UTC.
EndAtDateTimeOffsetДагенерация батчаКонец слота, UTC (EndAt − StartAt = длительность из параметров батча).
TimeZonestringДаvarchar(64), IANAиз батчаТаймзона батча — в ней задавались локальные окна (DST-корректность генерации).
BatchIdGuidДаindex ix_availability_slots_batch_idappИдентификатор публикации: связывает слоты одного батча (отчёт «создано/пропущено», массовая архивация).
AutoConfirmboolДаdefault falseиз батчаtrue — запись на слот сразу Confirmed (без решения агента); false — запись создаётся Pending.
CancellationCutoffMinutesintДа>= 0, default 120из батчаЗа сколько минут до StartAt инвестор ещё может отменить/перенести запись (INV-APT-3).
MinLeadTimeMinutesintДа>= 0, default 60из батчаМинимальный lead time: бронирование возможно только пока now ≤ StartAt − MinLeadTimeMinutes (INV-SLT-5).
ProjectIdGuid?НетappПроект застройщика (chessboard, логическая ссылка) — для слотов показа по проекту.
PropertyObjectIdGuid?НетappКонкретный объект недвижимости (chessboard, логическая ссылка).
Locationstring?Нетvarchar(512)appМесто встречи — свободный текст (без привязки к справочнику городов, ADR-0064 п.7).
CreatedAtDateTimeOffsetДаappПубликация слота.
UpdatedAtDateTimeOffsetДаapp / workerПоследнее изменение.
VersionintДаоптимистическая блокировкаappВерсия строки — арбитраж конкурентных переходов.

Enum SlotStatus

ЗначениеОписание
Available = 1Свободен: виден на витрине, доступен для бронирования.
Booked = 2Занят активной записью (Pending/Confirmed); с витрины скрыт.
Expired = 3Прошёл невостребованным (или освобождён записью слишком поздно для повторной продажи); с витрины скрыт.
Archived = 4Убран из оборота: вручную агентом либо каскадом завершённой записи. Терминальный.

Правила batch-генерации

Слоты создаются только batch-публикацией (../api/agent/publish-slots.md); поштучного создания нет.

  1. Вход публикации: диапазон дат (горизонт), дни недели, локальные окна доступности (HH:mm–HH:mm), длительность слота (минуты), TimeZone (IANA), Kind, AutoConfirm, CancellationCutoffMinutes, MinLeadTimeMinutes, опционально ProjectId/PropertyObjectId/Location. Некорректное окно (начало ≥ конца, длительность ≤ 0, пустые дни недели, горизонт целиком в прошлом) → CAL_SLOT_BATCH_INVALID_WINDOW.
  2. Кандидаты = локальные окна × дни недели × длительность: каждое окно каждого подходящего дня нарезается подряд идущими интервалами заданной длительности; неполный хвост окна отбрасывается.
  3. DST-корректность: кандидаты вычисляются в локальном времени агента по IANA TimeZone батча и конвертируются в UTC для хранения (окно «10:00–13:00 по вторникам» остаётся 10:00 локальными по обе стороны перевода часов — UTC-моменты различаются, это корректно). Локальное время, не существующее в день перевода часов, — кандидат пропускается; неоднозначное (повторяющийся час) — берётся первое вхождение.
  4. Lead time: кандидаты с StartAt < now + MinLeadTimeMinutes пропускаются.
  5. Пересечения: кандидаты, пересекающиеся с существующими слотами агента в статусах Available/Booked или друг с другом, пропускаются (INV-SLT-2).
  6. Лимит: не более 500 создаваемых слотов за одну публикацию → иначе CAL_SLOT_BATCH_LIMIT_EXCEEDED (весь батч отклоняется, ничего не создаётся).
  7. Результат — отчёт публикации: количество созданных и список пропущенных кандидатов с причинами (Overlap — пересечение, LeadTime — п.4, NonExistentLocalTime — п.3). Публикация атомарна: либо создан весь отчётный набор, либо ничего.

Связи

СвязьТипКлючDelete behavior
AppointmentEntityreference (активная запись занимает слот)Appointment.SlotId; не более одной активной (INV-APT-1)restrict
Проект / объект (chessboard)внешняя логическаяProjectId / PropertyObjectId

Инварианты и переходы состояний

ИнвариантУсловиеКогда проверяетсяОшибка
INV-SLT-1StartAt < EndAt; слот создаётся только batch-публикацией и только с StartAt ≥ now + MinLeadTimeMinutes (прошедшие кандидаты пропускаются)публикация батчаCAL_SLOT_BATCH_INVALID_WINDOW
INV-SLT-2Слоты одного AgentUserId в статусах Available/Booked не пересекаются по времени; пересекающиеся кандидаты батча пропускаются с отчётом (не ошибка публикации)публикация батча— (пропуск + отчёт)
INV-SLT-3Занять слот можно только условным переходом Available → Booked под построчной блокировкой, атомарно с созданием записи и календарного события; при конкуренции выигрывает ровно один запросбронирование (book-slot, перенос записи)SLOT_ALREADY_BOOKED
INV-SLT-4Слот в Booked управляется только каскадами переходов записи (таблица каскадов — appointment.md); прямая архивация Booked-слота агентом запрещенаarchive-slot, каскады записиCAL_SLOT_BOOKED
INV-SLT-5Бронирование — только пока now ≤ StartAt − MinLeadTimeMinutes; Expired выставляют worker ../background/slot-expiration.md (для Available при now ≥ StartAt) и каскад записи (когда возврат в Available уже не имеет смысла); переходы идемпотентны; Archived терминаленбронирование, worker, каскадыCAL_SLOT_LEAD_TIME_VIOLATION

Ошибки

КодHTTPУсловие
CAL_SLOT_NOT_FOUND404Слот не найден (или недоступен актору для управляющих операций).
CAL_SLOT_ACCESS_DENIED403Управляющая операция (публикация/архивация/просмотр своих слотов) без permission calendar.slots.manage.
SLOT_ALREADY_BOOKED409Гонка бронирования проиграна: слот уже Booked (условный переход не сработал; имя кода зафиксировано ADR-0064 п.3).
CAL_SLOT_NOT_AVAILABLE409Бронирование слота в статусе Expired/Archived.
CAL_SLOT_BOOKED409Прямая архивация Booked-слота (INV-SLT-4) — сначала нужно разрешить судьбу записи.
CAL_SLOT_LEAD_TIME_VIOLATION409Бронирование позже StartAt − MinLeadTimeMinutes (INV-SLT-5).
CAL_SLOT_BATCH_INVALID_WINDOW400Некорректные параметры генерации (окно, длительность, дни недели, горизонт).
CAL_SLOT_BATCH_LIMIT_EXCEEDED400Батч породил бы более 500 слотов.
CAL_SLOT_TIMEZONE_UNKNOWN400TimeZone батча — не валидный IANA-идентификатор.

Конкуренция и идемпотентность

  • Гонка бронирования — единственная точка конкуренции (ADR-0064 п.3): два одновременных book-slot на один слот → ровно одна запись создана, второй запрос детерминированно получает SLOT_ALREADY_BOOKED и выбирает другой слот. Hold'ов/TTL нет: «передумал» = отмена записи, слот возвращается каскадом.
  • Гонка «worker ↔ бронирование»: slot-expiration и book-slot конкурируют за Available-слот на границе StartAt; арбитраж — построчная блокировка/версия. Если истечение выиграло — бронирование получает CAL_SLOT_NOT_AVAILABLE.
  • Идемпотентность worker-а: повторный проход над уже Expired/Archived слотом — no-op (ADR-0056).
  • Повтор публикации батча: повторная публикация тех же окон не создаёт дублей — все кандидаты пересекаются с уже созданными слотами и пропускаются по INV-SLT-2 (отчёт: 0 создано).

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

Собственных интеграционных событий слот не публикует: занятие/освобождение видно downstream-потребителям через события записи pin.calendar.appointment-* (appointment.md); публикация и архивация слотов — внутреннее дело контекста.

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

АспектЗначение
ИндексыPK; ix_availability_slots_tenant_id; ix_availability_slots_agent_start (AgentUserId, StartAt) — календарь агента и проверка пересечений; ix_availability_slots_status_start (Status, StartAt) — витрина (Available + диапазон) и worker slot-expiration; ix_availability_slots_batch_id — отчёты/массовые операции. Непересечение слотов обеспечивается процедурой генерации (INV-SLT-2), а не ограничением хранения
Партиционирование / retentionnone (ADR-0056); рост числа строк ограничен лимитом батча (≤ 500), горизонтом публикации и worker-ом slot-expiration (см. риски ADR-0064)
Concurrencyпострочная блокировка на условном переходе Available → Booked; оптимистическая блокировка (Version) на остальных операциях
Permissions / PIIпубликация/архивация — calendar.slots.manage (владелец-агент); просмотр своих слотов — ../api/agent/list-slots.md; витрина свободных слотов — анонимная (../api/public/list-available-slots.md): payload без PII — слот не содержит данных клиента, отдаются только время/вид/привязка к проекту и display-данные агента
AuditCreatedAt/UpdatedAt; BatchId — след публикации; судьба занятого слота — через запись

Acceptance-критерии

  • Генерация. Дано: окно «Вт, Чт 10:00–13:00 Europe/Moscow», длительность 60 мин, горизонт 2 недели. Когда агент публикует батч. Тогда создано 12 слотов Available (по 3 на каждый из 4 дней), время хранится в UTC, отчёт: создано 12, пропущено 0.
  • DST. Дано: окно 10:00–12:00 в таймзоне с переводом часов, горизонт охватывает дату перевода. Тогда слоты по обе стороны перевода начинаются в 10:00 локальных (разные UTC-моменты); двойных/пропавших слотов нет.
  • Пересечение. Дано: у агента уже есть Available-слот 10:00–11:00 на вторник. Когда публикуется батч с окном 10:00–12:00 на вторник. Тогда кандидат 10:00–11:00 пропущен (Overlap), кандидат 11:00–12:00 создан; отчёт отражает оба исхода.
  • Лимит батча. Дано: параметры порождают 600 кандидатов. Тогда отказ CAL_SLOT_BATCH_LIMIT_EXCEEDED, ни один слот не создан.
  • Гонка. Дано: два одновременных book-slot на один Available-слот. Тогда ровно один переход Available → Booked и одна запись; второй запрос — SLOT_ALREADY_BOOKED.
  • Lead time. Дано: слот с StartAt через 30 минут, MinLeadTimeMinutes = 60. Когда инвестор бронирует. Тогда отказ CAL_SLOT_LEAD_TIME_VIOLATION.
  • Архив занятого. Дано: слот Booked. Когда агент вызывает archive-slot. Тогда отказ CAL_SLOT_BOOKED.
  • Истечение. Дано: Available-слот, now ≥ StartAt. Когда проходит worker. Тогда слот Expired, повторный проход ничего не меняет.

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

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

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

  • appointment.md — запись, занимающая слот (таблица каскадов «переход записи → эффект на слот/событие»); calendar-event.md — порождаемое календарное событие.
  • ADR-0064 п.3/6/7 — материализованные слоты, гонка без hold'ов, DST, вне-scope привязка к справочнику городов; ADR-0056 — без Redis, polling-worker-ы.
  • Контракты API: ../api/agent/publish-slots.md, ../api/agent/list-slots.md, ../api/agent/archive-slot.md, ../api/public/list-available-slots.md, ../api/public/book-slot.md.
  • Worker: ../background/slot-expiration.md.