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 |
| Kind | AggregateRoot |
| Source of truth | calendar |
| Таблица | calendar.availability_slots |
| Tenant isolation | TenantId — компания агента (агентство / ПИН), ADR-0052; публичная витрина слотов — анонимный read-only без tenant-контекста |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK; Guid v7 | app | Идентификатор слота. |
TenantId | Guid | Да | index ix_availability_slots_tenant_id | из агента | Компания агента. |
AgentUserId | Guid | Да | index ix_availability_slots_agent_start (AgentUserId, StartAt) | app | Агент/консультант — владелец слота. |
Kind | EventKind | Да | enum (хранится как text), varchar(32); только Showing/Consultation | app | Вид встречи, на которую открыт слот (подмножество EventKind). |
Status | SlotStatus | Да | enum (хранится как text), varchar(32), index ix_availability_slots_status_start (Status, StartAt) | app / worker / каскад записи | См. enum и stateDiagram. |
StartAt | DateTimeOffset | Да | check StartAt < EndAt | генерация батча | Начало слота, UTC. |
EndAt | DateTimeOffset | Да | — | генерация батча | Конец слота, UTC (EndAt − StartAt = длительность из параметров батча). |
TimeZone | string | Да | varchar(64), IANA | из батча | Таймзона батча — в ней задавались локальные окна (DST-корректность генерации). |
BatchId | Guid | Да | index ix_availability_slots_batch_id | app | Идентификатор публикации: связывает слоты одного батча (отчёт «создано/пропущено», массовая архивация). |
AutoConfirm | bool | Да | default false | из батча | true — запись на слот сразу Confirmed (без решения агента); false — запись создаётся Pending. |
CancellationCutoffMinutes | int | Да | >= 0, default 120 | из батча | За сколько минут до StartAt инвестор ещё может отменить/перенести запись (INV-APT-3). |
MinLeadTimeMinutes | int | Да | >= 0, default 60 | из батча | Минимальный lead time: бронирование возможно только пока now ≤ StartAt − MinLeadTimeMinutes (INV-SLT-5). |
ProjectId | Guid? | Нет | — | app | Проект застройщика (chessboard, логическая ссылка) — для слотов показа по проекту. |
PropertyObjectId | Guid? | Нет | — | app | Конкретный объект недвижимости (chessboard, логическая ссылка). |
Location | string? | Нет | varchar(512) | app | Место встречи — свободный текст (без привязки к справочнику городов, ADR-0064 п.7). |
CreatedAt | DateTimeOffset | Да | — | app | Публикация слота. |
UpdatedAt | DateTimeOffset | Да | — | app / worker | Последнее изменение. |
Version | int | Да | оптимистическая блокировка | app | Версия строки — арбитраж конкурентных переходов. |
Enum SlotStatus
| Значение | Описание |
|---|---|
Available = 1 | Свободен: виден на витрине, доступен для бронирования. |
Booked = 2 | Занят активной записью (Pending/Confirmed); с витрины скрыт. |
Expired = 3 | Прошёл невостребованным (или освобождён записью слишком поздно для повторной продажи); с витрины скрыт. |
Archived = 4 | Убран из оборота: вручную агентом либо каскадом завершённой записи. Терминальный. |
Правила batch-генерации
Слоты создаются только batch-публикацией (../api/agent/publish-slots.md); поштучного создания нет.
- Вход публикации: диапазон дат (горизонт), дни недели, локальные окна доступности (
HH:mm–HH:mm), длительность слота (минуты),TimeZone(IANA),Kind,AutoConfirm,CancellationCutoffMinutes,MinLeadTimeMinutes, опциональноProjectId/PropertyObjectId/Location. Некорректное окно (начало ≥ конца, длительность ≤ 0, пустые дни недели, горизонт целиком в прошлом) →CAL_SLOT_BATCH_INVALID_WINDOW. - Кандидаты = локальные окна × дни недели × длительность: каждое окно каждого подходящего дня нарезается подряд идущими интервалами заданной длительности; неполный хвост окна отбрасывается.
- DST-корректность: кандидаты вычисляются в локальном времени агента по IANA
TimeZoneбатча и конвертируются в UTC для хранения (окно «10:00–13:00 по вторникам» остаётся 10:00 локальными по обе стороны перевода часов — UTC-моменты различаются, это корректно). Локальное время, не существующее в день перевода часов, — кандидат пропускается; неоднозначное (повторяющийся час) — берётся первое вхождение. - Lead time: кандидаты с
StartAt < now + MinLeadTimeMinutesпропускаются. - Пересечения: кандидаты, пересекающиеся с существующими слотами агента в статусах
Available/Bookedили друг с другом, пропускаются (INV-SLT-2). - Лимит: не более 500 создаваемых слотов за одну публикацию → иначе
CAL_SLOT_BATCH_LIMIT_EXCEEDED(весь батч отклоняется, ничего не создаётся). - Результат — отчёт публикации: количество созданных и список пропущенных кандидатов с причинами
(
Overlap— пересечение,LeadTime— п.4,NonExistentLocalTime— п.3). Публикация атомарна: либо создан весь отчётный набор, либо ничего.
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
AppointmentEntity | reference (активная запись занимает слот) | Appointment.SlotId; не более одной активной (INV-APT-1) | restrict |
Проект / объект (chessboard) | внешняя логическая | ProjectId / PropertyObjectId | — |
Инварианты и переходы состояний
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|---|---|---|
| INV-SLT-1 | StartAt < 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_FOUND | 404 | Слот не найден (или недоступен актору для управляющих операций). |
CAL_SLOT_ACCESS_DENIED | 403 | Управляющая операция (публикация/архивация/просмотр своих слотов) без permission calendar.slots.manage. |
SLOT_ALREADY_BOOKED | 409 | Гонка бронирования проиграна: слот уже Booked (условный переход не сработал; имя кода зафиксировано ADR-0064 п.3). |
CAL_SLOT_NOT_AVAILABLE | 409 | Бронирование слота в статусе Expired/Archived. |
CAL_SLOT_BOOKED | 409 | Прямая архивация Booked-слота (INV-SLT-4) — сначала нужно разрешить судьбу записи. |
CAL_SLOT_LEAD_TIME_VIOLATION | 409 | Бронирование позже StartAt − MinLeadTimeMinutes (INV-SLT-5). |
CAL_SLOT_BATCH_INVALID_WINDOW | 400 | Некорректные параметры генерации (окно, длительность, дни недели, горизонт). |
CAL_SLOT_BATCH_LIMIT_EXCEEDED | 400 | Батч породил бы более 500 слотов. |
CAL_SLOT_TIMEZONE_UNKNOWN | 400 | TimeZone батча — не валидный 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), а не ограничением хранения |
| Партиционирование / retention | none (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-данные агента |
| Audit | CreatedAt/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, повторный проход ничего не меняет.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: AppointmentEntity, Entity / Aggregate: CalendarEventEntity
- API (14): GET /api/public/calendar/slots — Витрина свободных слотов самозаписи, GET /api/v1/agency/calendar/appointments — Записи команды (read-only), GET /api/v1/agent/calendar/appointments — Список записей к агенту, GET /api/v1/agent/calendar/appointments/{appointmentId} — Карточка записи, GET /api/v1/agent/calendar/slots — Список слотов доступности агента, GET /api/v1/investor/calendar/appointments — Мои записи на встречи, POST /api/public/calendar/appointments — Самозапись на слот (бронирование), POST /api/v1/agent/calendar/appointments/{appointmentId}/cancel — Отмена записи агентом, POST /api/v1/agent/calendar/appointments/{appointmentId}/complete — Фиксация исхода встречи, POST /api/v1/agent/calendar/appointments/{appointmentId}/decline — Отклонение записи, POST /api/v1/agent/calendar/slots — Публикация слотов доступности (batch), POST /api/v1/agent/calendar/slots/archive — Архивация слотов (поштучно или по батчу) (+2)
- Use Cases (6): UC-CAL-002. Агент публикует слоты доступности, UC-CAL-003. Инвестор бронирует слот с витрины, UC-CAL-004. Агент подтверждает или отклоняет запись, UC-CAL-005. Отмена и перенос записи, UC-CAL-006. Агент фиксирует исход встречи, US-реестр контекста calendar (US-CAL)
- События (3): Event Contract: pin.calendar.appointment-cancelled (AppointmentCancelledEvent), Event Contract: pin.calendar.appointment-completed (AppointmentCompletedEvent), Event Contract: pin.calendar.appointment-created (AppointmentCreatedEvent)
- Консюмеры (1): Consumer: Calendar Identity User Sync (calendar-identity-users)
- Фоновые задачи (2): Background Job: AppointmentTimeoutJob, Background Job: SlotExpirationJob
- index.md (1): Calendar Service
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- 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.