v1.0 Spec 01 — CRM: Calendar & Schedule
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
v1.0 Spec 01 — CRM: Calendar & Schedule
Паспорт документа
- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
Version: v1.0 · Priority: P0 · Phase: Paid CRM (SaaS) Status: Draft v1
1. Контекст и цель
CRM Calendar & Schedule — фундаментальный модуль CRM-системы Qadam. Он управляет временем: когда работают преподаватели, когда проходят занятия, какие слоты свободны для записи. Без этого модуля невозможны ни онлайн-запись с карточки айтема, ни управление группами, ни аналитика загрузки.
Два уровня расписания (dual-level calendar):
Уровень 1: Рабочий график преподавателя
→ Когда преподаватель работает (рабочие дни, часы, перерывы)
→ Исключения: отпуска, больничные, разовые отмены
Уровень 2: Расписание группы / курса
→ Конкретные дни недели и время занятий группы
→ Привязывается к преподавателю → "блокирует" слоты в его графике
→ Свободные слоты = рабочее время - занятые группами/записями
Связь с онлайн-записью (Spec v1-03):
- Для групп (
group,mini_group): покупатель видит список активных групп с расписанием → выбирает → бронирует место - Для индивидуальных занятий (
one_on_one): покупатель видит свободные слоты преподавателя → выбирает → бронирует
Что НЕ входит в этот модуль:
- Клиентская база (профили учеников) → Spec v1-02
- UI онлайн-записи с публичной карточки айтема → Spec v1-03
- CRM-роли и матрица доступов → Spec v1-04
- SaaS-биллинг и активация CRM → Spec v1-05
- Уведомления о бронированиях → Spec MVP-10 (расширяется в v1.0)
2. Роли пользователей
| Роль | Действие в этом модуле |
|---|---|
| Owner | Настраивает графики всех преподавателей, создаёт/редактирует группы, просматривает сводный календарь |
| Admin CRM | То же что Owner, кроме биллинга |
| Manager | Просматривает расписания, создаёт индивидуальные записи, отмечает посещаемость |
| Teacher | Видит только своё расписание и свои группы, отмечает посещаемость своих занятий |
| Buyer (Parent/Student) | Видит расписание групп / свободные слоты на публичной карточке айтема, бронирует место (через Spec v1-03) |
3. Use Cases
UC-01: Настройка рабочего графика преподавателя
Актор: Owner / Admin CRM
Предусловие: Продавец активировал CRM-подписку, преподаватель уже добавлен как SellerStaff с ролью teacher
Триггер: Нужно задать, когда преподаватель работает, чтобы система могла вычислять свободные слоты
Полный поток:
[Точка входа]
→ Owner/Admin находится в /seller/crm/staff
→ Видит таблицу с сотрудниками: имя, роль, статус, столбец "График работы"
→ У преподавателя без графика в столбце "График работы" отображается:
⚠ Не настроен [Настроить]
→ Нажимает кнопку [Настроить] или переходит по ссылке на карточку сотрудника
─────────────────────────────────────────────────────────
ШАГ 1 — Карточка сотрудника, вкладка "График работы"
─────────────────────────────────────────────────────────
→ Открывается /seller/crm/staff/[staffId]/schedule
→ Страница разделена на две секции:
[Рабочий график] [Исключения]
→ В секции "Рабочий график" — форма с чекбоксами дней недели:
☐ Понедельник [09:00 ▼] — [18:00 ▼] Перерыв: [13:00 ▼] — [14:00 ▼]
☐ Вторник [09:00 ▼] — [18:00 ▼] Перерыв: [13:00 ▼] — [14:00 ▼]
☐ Среда [09:00 ▼] — [18:00 ▼] Перерыв: [13:00 ▼] — [14:00 ▼]
☐ Четверг [09:00 ▼] — [18:00 ▼] Перерыв: [13:00 ▼] — [14:00 ▼]
☐ Пятница [09:00 ▼] — [18:00 ▼] Перерыв: [13:00 ▼] — [14:00 ▼]
☐ Суббота [09:00 ▼] — [14:00 ▼] Перерыв: —
☐ Воскресенье [09:00 ▼] — [14:00 ▼] Перерыв: —
[Дата начала действия графика] * — datepicker, по умолчанию = сегодня
[Дата окончания] — datepicker, опционально (открытый конец = работает бессрочно)
→ Admin выбирает рабочие дни (ставит галочки), задаёт часы и перерывы
→ Нажимает [Сохранить график]
─────────────────────────────────────────────────────────
ШАГ 2 — Подтверждение сохранения
─────────────────────────────────────────────────────────
→ Система создаёт WorkSchedule + WorkScheduleDay (по одной записи на каждый
выбранный день недели)
→ Показывает success-уведомление (toast):
✅ "График работы сохранён. Теперь система будет вычислять свободные слоты."
→ Страница обновляется: чекбоксы уже заполнены, кнопка меняется на [Изменить]
→ В таблице сотрудников столбец "График работы" теперь показывает:
✅ Пн–Пт, 09:00–18:00
UC-01 — Альтернативные потоки
Ошибки валидации
1a. Не выбран ни один день:
- Система подсвечивает секцию красной рамкой
- ⚠ "Выберите хотя бы один рабочий день"
- Кнопка [Сохранить] заблокирована до исправления
1b. Время окончания раньше времени начала:
- Поле "Конец" подсвечивается красной рамкой
- ⚠ "Время окончания должно быть позже начала"
1c. Перерыв выходит за рамки рабочего времени:
- ⚠ "Время перерыва должно быть внутри рабочих часов"
1d. Новый график перекрывается с существующим активным графиком:
- ⚠ "У преподавателя уже есть активный график до [дата]. Укажите дату начала нового графика позже или завершите текущий."
- Ссылка: [Посмотреть текущий график]
1e. Дата окончания раньше даты начала:
- ⚠ "Дата окончания не может быть раньше даты начала"
UC-02: Добавление исключения в график преподавателя
Актор: Owner / Admin CRM / Manager Предусловие: У преподавателя уже настроен рабочий график Триггер: Нужно заблокировать конкретные даты (отпуск, болезнь, разовая отмена)
Полный поток:
[Точка входа]
→ Admin находится на /seller/crm/staff/[staffId]/schedule
→ В секции "Исключения" — пустой список + кнопка [+ Добавить исключение]
→ Нажимает кнопку
─────────────────────────────────────────────────────────
ШАГ 1 — Модал "Добавить исключение"
─────────────────────────────────────────────────────────
→ Открывается модал с формой:
Тип исключения: [● Отпуск] [○ Больничный] [○ Личная причина] [○ Разовая отмена]
Дата начала *: [datepicker]
Дата окончания *: [datepicker]
— если выбрана одна дата для начала и конца → разовая отмена одного дня
— диапазон дат → блокирует весь период
Примечание (опционально): [textarea, max 200 символов]
[Отмена] [Сохранить]
→ Admin заполняет форму → нажимает [Сохранить]
─────────────────────────────────────────────────────────
ШАГ 2 — Обработка конфликтов с уже забронированными слотами
─────────────────────────────────────────────────────────
→ Система проверяет: есть ли в выбранном периоде уже подтверждённые
бронирования (IndividualBooking) или плановые занятия групп (GroupSession)
[Если конфликтов нет]:
→ Исключение создаётся
→ Toast: ✅ "Исключение добавлено. Слоты в этот период теперь недоступны."
→ Модал закрывается, исключение появляется в списке
[Если есть конфликты]:
→ Показывается предупреждение прямо в модале:
⚠ В этот период есть {N} запланированных занятий:
• 15 марта, 10:00 — Иванов Алексей (Английский, 1:1)
• 17 марта, 14:00 — Группа "English A1 Morning"
Что сделать с этими занятиями?
[○ Отменить все занятия и уведомить клиентов]
[○ Оставить как есть (исключение не затронет их)]
[← Назад] [Подтвердить]
→ Admin выбирает действие → нажимает [Подтвердить]
→ Если выбрано "Отменить": все затронутые сессии переводятся в статус
`cancelled`, клиентам отправляются уведомления
→ Исключение сохраняется, список обновляется
UC-02 — Альтернативные потоки
2a. Пересечение с уже существующим исключением:
- ⚠ "Период пересекается с уже добавленным исключением: [тип, даты]. Скорректируйте даты."
2b. Дата в прошлом:
- Система разрешает добавить исключение в прошлом (нужно для корректного ведения истории)
- Но показывает информационное сообщение:
- ℹ️ "Вы добавляете исключение задним числом. Это повлияет на отчёты."
UC-03: Создание учебной группы
Актор: Owner / Admin CRM
Предусловие: Существует Item с item_studytype = group или mini_group, есть хотя бы один Teacher с настроенным графиком
Триггер: Нужно запустить новую учебную группу
Полный поток:
[Точка входа]
→ Owner/Admin находится на /seller/crm/groups
→ Видит список существующих групп или пустое состояние:
"У вас пока нет учебных групп. Создайте первую."
→ Нажимает кнопку [+ Создать группу]
─────────────────────────────────────────────────────────
ШАГ 1 — Выбор курса
─────────────────────────────────────────────────────────
→ Открывается страница /seller/crm/groups/new
→ Шаг 1 из 3: "Выберите курс"
Поиск курса: [Начните вводить название…]
Или выберите из списка активных курсов с типом "Группа" или "Мини-группа":
┌──────────────────────────────────────────────────────┐
│ 🎯 Английский язык A1 · Группа · 6–10 лет │
│ 🎯 Программирование Python · Мини-группа · 12–17 лет │
│ 🎯 Рисование акварель · Группа · 5–8 лет │
└──────────────────────────────────────────────────────┘
→ Admin выбирает курс → нажимает [Далее]
─────────────────────────────────────────────────────────
ШАГ 2 — Параметры группы
─────────────────────────────────────────────────────────
→ Форма:
Название группы * — автозаполнение: "[Название курса] — [Время]", редактируется
Пример: "English A1 — утро"
Преподаватель *: [выпадающий список Teachers организации]
— Показывает только сотрудников с ролью teacher и настроенным графиком
— Если нет подходящих: "Сначала добавьте преподавателя и настройте его график"
Максимальное количество учеников *: [числовое поле, min 1]
Аудитория / кабинет (опционально): [text field]
Дата начала группы *: [datepicker]
Дата окончания (опционально): [datepicker]
→ Admin заполняет → нажимает [Далее]
─────────────────────────────────────────────────────────
ШАГ 3 — Расписание группы
─────────────────────────────────────────────────────────
→ Форма выбора дней и времени занятий:
Занятия проводятся:
☐ Пн ☐ Вт ☑ Ср ☐ Чт ☑ Пт ☐ Сб ☐ Вс
Время начала занятия *: [10:00 ▼]
Длительность занятия *: [60 мин ▼] — автозаполняется из карточки айтема
→ Превью: "Занятия: ср, пт · 10:00–11:00"
─────────────────────────────────────────────────
Проверка конфликтов в реальном времени:
─────────────────────────────────────────────────
→ Система проверяет по мере ввода: не пересекается ли выбранное время
с другими группами этого преподавателя в выбранные дни
[Нет конфликтов]:
✅ "Время свободно. Преподаватель доступен в выбранные дни."
[Есть конфликт]:
⚠ "В среду 10:00–11:00 преподаватель уже ведёт группу 'English B1'.
Выберите другое время или другого преподавателя."
→ Admin убеждается что конфликтов нет → нажимает [Создать группу]
─────────────────────────────────────────────────────────
ШАГ 4 — Подтверждение создания
─────────────────────────────────────────────────────────
→ Система:
1. Создаёт StudyGroup (статус: active)
2. Создаёт GroupSchedule (повторяющийся паттерн)
3. Генерирует GroupSession записи на ближайшие 60 дней (lazy generation)
4. Блокирует соответствующие слоты в графике преподавателя
→ Toast: ✅ "Группа '{название}' создана. Расписание настроено на ср, пт 10:00."
→ Редирект на /seller/crm/groups/[groupId]
→ Страница группы показывает:
- Название, курс, преподаватель, кол-во мест: 0 / {max}
- Вкладки: [Расписание] [Ученики] [Посещаемость]
- Кнопка [Показать на карточке курса] — переключатель (toggle)
UC-03 — Альтернативные потоки
3a. Нет активных курсов с типом группа:
- На шаге 1 пустой список + ссылка: "Сначала создайте курс с типом 'Группа' в разделе Мои курсы → [Создать курс]"
3b. Преподаватель не имеет графика на выбранные дни:
- ⚠ "Преподаватель [Имя] не работает в {день недели}. Выберите другой день или другого преподавателя."
3c. Выбранное время выходит за рамки рабочего времени преподавателя:
- ⚠ "Занятие в 17:30–18:30 выходит за рамки рабочего времени преподавателя (до 18:00). Скорректируйте время."
3d. Время занятия пересекается с перерывом преподавателя:
- ⚠ "В это время у преподавателя перерыв (13:00–14:00). Выберите время до 13:00 или после 14:00."
UC-04: Просмотр сводного CRM-календаря
Актор: Owner / Admin CRM / Manager / Teacher (только свои данные) Предусловие: Существуют группы и/или индивидуальные записи Триггер: Нужно увидеть общую картину расписания за день / неделю / месяц
Полный поток:
[Точка входа]
→ Пользователь находится в /seller/crm
→ В левом меню выбирает "Календарь"
→ Открывается /seller/crm/calendar
─────────────────────────────────────────────────────────
ГЛАВНЫЙ ЭКРАН: CRM Calendar
─────────────────────────────────────────────────────────
Панель управления (toolbar):
[← Назад] [Сегодня] [Вперёд →] [День] [Неделя] [Месяц]
Фильтр: [Все преподаватели ▼] [Все курсы ▼]
─────────────────────────────────────────────────────────
Вид "Неделя" (default):
─────────────────────────────────────────────────────────
Пн 17 | Вт 18 | Ср 19 | Чт 20 | Пт 21 | Сб 22 | Вс 23
───────────────────────────────────────────────────────────────────────
09:00 │ │ │[English]│ │[English]│ │
09:30 │ │ │ A1 │ │ A1 │ │
10:00 │ │[Python] │ (5/8) │ │ (5/8) │[Рисов.] │
10:30 │ │ Mini │ │ │ │ Акваре- │
11:00 │ │ (3/4) │ │ │ │ ль (6/8)│
11:30 │ │ │ │ │ │ │
...
14:00 │[1:1 Ива- │ │ │[1:1 Пет-│ │ │
14:30 │ нов А.] │ │ │ ров К.]│ │ │
15:00 │ │ │ │ │ │ │
Цветовая кодировка:
🟢 Зелёный — групповые занятия (group / mini_group)
🔵 Синий — индивидуальные записи (one_on_one)
🔴 Красный — отменённые занятия
⬜ Серый — недоступное время (перерывы, исключения)
На каждом событии в сетке показывается:
- Название группы или "1:1 + имя клиента"
- Заполненность для групп: (5/8)
- Клик на событие → открывается боковая панель с деталями
─────────────────────────────────────────────────────────
Вид "День":
─────────────────────────────────────────────────────────
→ Временная шкала с шагом 30 минут
→ Если выбран фильтр "Все преподаватели" — колонки по преподавателям:
| Время | Иванова А. | Петров К. | Смирнов Д. |
| 09:00 | English A1 | — | Python |
| 10:00 | — | 1:1 Абдулла| — |
─────────────────────────────────────────────────────────
Вид "Месяц":
─────────────────────────────────────────────────────────
→ Сетка дней месяца
→ В каждом дне — "пилюли" с числом событий:
[3 занятия] [1 запись]
→ Клик на день → открывается Day View этого дня
─────────────────────────────────────────────────────────
Боковая панель деталей (при клике на событие):
─────────────────────────────────────────────────────────
Для GroupSession:
📅 Среда, 19 марта 2026, 10:00–11:00
👥 Группа: English A1 Morning
🎓 Курс: Английский язык A1
👨🏫 Преподаватель: Иванова Алина
🏫 Аудитория: Кабинет №3
Ученики (5/8):
✅ Иванов Алексей
✅ Петрова Мария
❌ Сидоров Дмитрий (не пришёл)
✅ Ахмедов Нодир
✅ Каримова Зарина
[Отметить посещаемость] [Отменить занятие]
Для IndividualBooking:
📅 Понедельник, 17 марта 2026, 14:00–15:00
👤 Клиент: Иванов Алексей (родитель: Иванова Светлана, +998 90 123-45-67)
🎓 Курс: Английский язык (индивидуально)
👨🏫 Преподаватель: Иванова Алина
[Изменить время] [Отменить запись]
UC-04 — Альтернативные потоки
4a. Для роли Teacher:
- Фильтр "Все преподаватели" не показывается — учитель видит только свои данные
- Нет кнопок управления другими сотрудниками
4b. Нажатие на свободный слот:
- Открывается мини-форма быстрого создания события: "Создать: [Индивидуальную запись] [Занятие группы]"
- Время предзаполняется выбранным слотом
4c. Нет событий в выбранном периоде:
- Показывается пустое состояние: "В этот период занятий нет. [+ Создать запись]"
UC-05: Маркировка посещаемости
Актор: Teacher / Manager / Admin CRM
Предусловие: GroupSession находится в статусе scheduled, дата сессии = сегодня или в прошлом
Триггер: Нужно отметить кто пришёл на занятие
Полный поток:
[Точка входа — вариант A: из календаря]
→ Teacher открывает /seller/crm/calendar
→ Видит своё занятие сегодня: "English A1 · 10:00-11:00"
→ Нажимает на событие → боковая панель
→ Нажимает [Отметить посещаемость]
[Точка входа — вариант B: из списка групп]
→ Teacher открывает /seller/crm/groups/[groupId]
→ Вкладка "Посещаемость"
→ Видит список занятий → рядом с занятием кнопка [Отметить]
─────────────────────────────────────────────────────────
ЭКРАН: Маркировка посещаемости
─────────────────────────────────────────────────────────
Заголовок: "Посещаемость — Среда, 19 марта 2026, 10:00"
Группа: English A1 Morning | Преподаватель: Иванова Алина
Список учеников (5 записей в группе):
┌────────────────────────────────────────────────────┐
│ Иванов Алексей [✅ Пришёл] [❌ Не пришёл] [⚠ Опоздал] [📝 Уважительная] │
│ Петрова Мария [✅ Пришёл] ... │
│ Сидоров Дмитрий ... │
│ Ахмедов Нодир ... │
│ Каримова Зарина ... │
└────────────────────────────────────────────────────┘
Кнопки быстрого выбора:
[Отметить всех присутствующими]
Заметка к занятию (опционально): [textarea]
[Сохранить посещаемость]
─────────────────────────────────────────────────────────
После сохранения:
─────────────────────────────────────────────────────────
→ Все AttendanceRecord созданы
→ GroupSession переводится в статус `completed`
→ Toast: ✅ "Посещаемость отмечена: 4/5 пришли"
→ В аналитике обновляется метрика `attendance_rate` для преподавателя и группы
UC-05 — Альтернативные потоки
5a. Попытка отметить посещаемость для будущего занятия:
- Кнопка [Отметить посещаемость] недоступна (disabled)
- Тултип: "Посещаемость можно отметить только в день занятия или после"
5b. Посещаемость уже была отмечена:
- Кнопка [Редактировать посещаемость] вместо [Отметить]
- Manager/Admin могут редактировать, Teacher — только до конца дня
- При редактировании показывается: "Последнее изменение: {дата} · {пользователь}"
5c. Занятие было отменено (GroupSession.status = cancelled):
- Блок посещаемости не показывается
- Отображается: "🚫 Занятие было отменено · Причина: {cancellation_reason}"
UC-06: Отмена занятия / группы
Актор: Owner / Admin CRM / Manager
Предусловие: Существует GroupSession в статусе scheduled или вся группа
Триггер: Нужно отменить одно занятие или приостановить / завершить группу
Полный поток:
[Точка входа: отмена разового занятия]
→ Admin открывает занятие в календаре
→ В боковой панели нажимает [Отменить занятие]
→ Появляется конфирмационный модал:
"Отменить занятие?"
Среда, 19 марта 2026, 10:00–11:00 · English A1 Morning
Причина отмены (опционально): [text field]
☐ Уведомить записанных учеников / родителей
[← Отмена] [Отменить занятие]
→ Admin подтверждает
→ GroupSession.status → `cancelled`
→ Слот в графике преподавателя освобождается для этой даты
→ Если выбрано "Уведомить" — отправляются уведомления (Telegram/email)
─────────────────────────────────────────────────────────
[Точка входа: завершение или приостановка группы]
─────────────────────────────────────────────────────────
→ Admin открывает /seller/crm/groups/[groupId]
→ Меню "⋮ Действия" → [Приостановить группу] / [Завершить группу]
[Приостановить]:
→ Модал: "Приостановить группу?"
Дата приостановки: [datepicker] — занятия не будут генерироваться с этой даты
[Возобновить автоматически]: [datepicker] (опционально)
[Подтвердить]
[Завершить]:
→ Модал: "Завершить группу?"
"Все запланированные занятия после сегодня будут отменены.
Ученики остаются в клиентской базе."
[Подтвердить завершение]
→ StudyGroup.status → `completed`
→ Все будущие GroupSession → `cancelled`
4. Бизнес-правила
BR-01: Двухуровневое расписание
- Рабочий график преподавателя (WorkSchedule) определяет когда преподаватель может работать
- Расписание группы (GroupSchedule) определяет когда фактически проходят занятия
- Занятия группы обязаны укладываться в рабочий график преподавателя
- Система не позволяет создать группу с расписанием в нерабочее время преподавателя
BR-02: Временная зона
- Все времена хранятся в UTC в базе данных
- Все времена отображаются в часовом поясе
Asia/Tashkent(UTC+5, без перехода на летнее время) - Часовой пояс преподавателя = часовой пояс организации (не настраивается отдельно в v1.0)
BR-03: Конфликты в расписании
- Один преподаватель не может вести два занятия одновременно
- Система проверяет пересечение временных отрезков с точностью до минуты
- Пересечение =
start_a < end_b AND start_b < end_a - Конфликты блокируют сохранение (hard block), не предупреждения
BR-04: Генерация сессий (GroupSession)
- При создании группы система генерирует сессии на 60 дней вперёд (lazy generation)
- Ежедневный cron-job догенерирует сессии: всегда поддерживается горизонт 60 дней
- Сессии не генерируются на даты попадающие в исключения преподавателя (
TeacherScheduleException) - При добавлении нового исключения — уже созданные сессии в период исключения → статус
cancelled - При удалении исключения — сессии восстанавливаются в
scheduled(если дата ещё не прошла)
BR-05: Вместимость групп
StudyGroup.max_capacity— максимальное число активных учениковcurrent_enrollment= COUNT(GroupEnrollment WHERE status = 'active')- При попытке добавить ученика сверх лимита → ошибка: "Группа заполнена. Максимум {max} учеников."
- Список ожидания (waitlist) — не реализуется в v1.0, только в v1.5
BR-06: Индивидуальные слоты (1:1)
- Свободный слот = ячейка рабочего времени преподавателя, не занятая:
- Занятием группы (GroupSession)
- Другой индивидуальной записью (IndividualBooking)
- Перерывом (WorkScheduleDay.break_start — break_end)
- Исключением (TeacherScheduleException)
- Длина слота =
item.item_studyduration_from(в минутах) - Слоты генерируются "на лету" при запросе, не хранятся в БД
BR-07: CRM доступен только при активной подписке
- Все
/seller/crm/*маршруты проверяют:CrmSubscription.status == active - При неактивной подписке — редирект на
/seller/crm/billingс промо-страницей - API возвращает
403 Forbiddenсreason: "crm_subscription_required"
BR-08: Права доступа в CRM (краткая сводка, полная матрица — Spec v1-04)
| Действие | Owner | Admin | Manager | Teacher |
|---|---|---|---|---|
| Создать/редактировать график любого преп. | ✅ | ✅ | ❌ | ❌ |
| Создать/редактировать группу | ✅ | ✅ | ❌ | ❌ |
| Просматривать все расписания | ✅ | ✅ | ✅ | ❌ |
| Просматривать своё расписание | ✅ | ✅ | ✅ | ✅ |
| Создать индивидуальную запись | ✅ | ✅ | ✅ | ❌ |
| Отменить занятие (не своё) | ✅ | ✅ | ✅ | ❌ |
| Отменить своё занятие | ✅ | ✅ | ✅ | ✅ |
| Отметить посещаемость (своя группа) | ✅ | ✅ | ✅ | ✅ |
| Отметить посещаемость (чужая группа) | ✅ | ✅ | ✅ | ❌ |
BR-09: Статусная модель GroupSession
scheduled → completed (после маркировки посещаемости)
scheduled → cancelled (отмена занятия / исключение / завершение группы)
cancelled → scheduled (только если отмена была через исключение, которое затем удалили)
completed → completed (финальный статус, только редактирование посещаемости)
BR-10: Статусная модель StudyGroup
active → paused (приостановка Owner/Admin)
active → completed (завершение Owner/Admin)
paused → active (возобновление Owner/Admin)
paused → completed (завершение из паузы)
5. Модель данных
Prisma Schema
// ─────────────────────────────────────────────────────
// Рабочий график преподавателя
// ─────────────────────────────────────────────────────
model WorkSchedule {
id String @id @default(uuid())
sellerId String @map("seller_id")
staffId String @map("staff_id") // FK → SellerStaff
effectiveFrom DateTime @map("effective_from") // UTC
effectiveTo DateTime? @map("effective_to") // null = бессрочный
timezone String @default("Asia/Tashkent")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
staff SellerStaff @relation(fields: [staffId], references: [id])
days WorkScheduleDay[]
exceptions TeacherScheduleException[]
@@index([staffId])
@@index([sellerId])
@@map("work_schedules")
}
model WorkScheduleDay {
id String @id @default(uuid())
scheduleId String @map("schedule_id")
dayOfWeek Int @map("day_of_week") // 1=Пн, 2=Вт, ..., 7=Вс (ISO 8601)
startTime String @map("start_time") // "HH:MM" формат, локальное время
endTime String @map("end_time") // "HH:MM"
breakStart String? @map("break_start") // "HH:MM", nullable
breakEnd String? @map("break_end") // "HH:MM", nullable
schedule WorkSchedule @relation(fields: [scheduleId], references: [id])
@@unique([scheduleId, dayOfWeek])
@@map("work_schedule_days")
}
// ─────────────────────────────────────────────────────
// Исключения в графике преподавателя
// ─────────────────────────────────────────────────────
enum ScheduleExceptionType {
vacation
sick_leave
personal
cancelled
}
model TeacherScheduleException {
id String @id @default(uuid())
scheduleId String @map("schedule_id")
exceptionType ScheduleExceptionType @map("exception_type")
dateFrom DateTime @map("date_from") // DATE only (без времени)
dateTo DateTime @map("date_to") // DATE only
note String? @db.VarChar(200)
createdById String @map("created_by_id") // FK → Account
createdAt DateTime @default(now()) @map("created_at")
schedule WorkSchedule @relation(fields: [scheduleId], references: [id])
@@index([scheduleId])
@@map("teacher_schedule_exceptions")
}
// ─────────────────────────────────────────────────────
// Учебные группы
// ─────────────────────────────────────────────────────
enum StudyGroupStatus {
active
paused
completed
cancelled
}
model StudyGroup {
id String @id @default(uuid())
sellerId String @map("seller_id")
itemId String @map("item_id") // FK → Item (type=group/mini_group)
staffId String @map("staff_id") // FK → SellerStaff (teacher)
name String @db.VarChar(200)
maxCapacity Int @map("max_capacity")
room String? @db.VarChar(100)
status StudyGroupStatus @default(active)
startDate DateTime @map("start_date") // DATE
endDate DateTime? @map("end_date") // DATE, null = бессрочно
isPublic Boolean @default(false) @map("is_public") // показывать на карточке айтема
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
schedules GroupSchedule[]
sessions GroupSession[]
enrollments GroupEnrollment[]
@@index([sellerId])
@@index([itemId])
@@index([staffId])
@@map("study_groups")
}
// Паттерн повторения расписания группы (еженедельный)
model GroupSchedule {
id String @id @default(uuid())
groupId String @map("group_id")
dayOfWeek Int @map("day_of_week") // 1=Пн ... 7=Вс
startTime String @map("start_time") // "HH:MM"
durationMinutes Int @map("duration_minutes")
effectiveFrom DateTime @map("effective_from")
effectiveTo DateTime? @map("effective_to")
group StudyGroup @relation(fields: [groupId], references: [id])
@@index([groupId])
@@map("group_schedules")
}
// ─────────────────────────────────────────────────────
// Конкретные занятия (инстанции из повторяющегося паттерна)
// ─────────────────────────────────────────────────────
enum GroupSessionStatus {
scheduled
completed
cancelled
}
model GroupSession {
id String @id @default(uuid())
groupId String @map("group_id")
groupScheduleId String @map("group_schedule_id")
sessionDate DateTime @map("session_date") // DATE
startTime String @map("start_time") // "HH:MM" (может быть изменено разово)
endTime String @map("end_time")
status GroupSessionStatus @default(scheduled)
cancellationReason String? @map("cancellation_reason")
sessionNote String? @map("session_note") @db.Text
cancelledById String? @map("cancelled_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
group StudyGroup @relation(fields: [groupId], references: [id])
attendanceRecords AttendanceRecord[]
@@index([groupId])
@@index([sessionDate])
@@map("group_sessions")
}
// ─────────────────────────────────────────────────────
// Записи учеников в группу
// ─────────────────────────────────────────────────────
enum GroupEnrollmentStatus {
active
paused
dropped
completed
}
model GroupEnrollment {
id String @id @default(uuid())
groupId String @map("group_id")
clientId String @map("client_id") // FK → CrmClient (Spec v1-02)
enrolledAt DateTime @default(now()) @map("enrolled_at")
enrolledById String @map("enrolled_by_id") // FK → Account
status GroupEnrollmentStatus @default(active)
droppedAt DateTime? @map("dropped_at")
dropReason String? @map("drop_reason")
group StudyGroup @relation(fields: [groupId], references: [id])
@@unique([groupId, clientId])
@@index([groupId])
@@index([clientId])
@@map("group_enrollments")
}
// ─────────────────────────────────────────────────────
// Посещаемость
// ─────────────────────────────────────────────────────
enum AttendanceStatus {
present
absent
excused
late
}
model AttendanceRecord {
id String @id @default(uuid())
sessionId String @map("session_id")
enrollmentId String @map("enrollment_id") // FK → GroupEnrollment
status AttendanceStatus
note String? @db.VarChar(500)
markedById String @map("marked_by_id") // FK → Account
markedAt DateTime @default(now()) @map("marked_at")
updatedAt DateTime @updatedAt @map("updated_at")
session GroupSession @relation(fields: [sessionId], references: [id])
@@unique([sessionId, enrollmentId])
@@index([sessionId])
@@index([enrollmentId])
@@map("attendance_records")
}
// ─────────────────────────────────────────────────────
// Индивидуальные записи (1:1)
// ─────────────────────────────────────────────────────
enum IndividualBookingStatus {
pending // создана, ждёт подтверждения
confirmed // подтверждена продавцом или автоматически
completed // занятие прошло
cancelled // отменена
no_show // клиент не пришёл
}
enum BookingSource {
crm // создана менеджером в CRM
public // создана клиентом через карточку айтема
}
model IndividualBooking {
id String @id @default(uuid())
sellerId String @map("seller_id")
itemId String @map("item_id")
staffId String @map("staff_id") // учитель
clientId String @map("client_id") // FK → CrmClient
bookingDate DateTime @map("booking_date") // DATE
startTime String @map("start_time") // "HH:MM"
endTime String @map("end_time")
status IndividualBookingStatus @default(pending)
source BookingSource @default(crm)
note String? @db.Text
cancelReason String? @map("cancel_reason")
cancelledById String? @map("cancelled_by_id")
createdById String @map("created_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([sellerId])
@@index([staffId])
@@index([clientId])
@@index([bookingDate])
@@map("individual_bookings")
}
6. Технические контракты
TypeScript DTOs
// ────────────────────────────────────────────
// Work Schedule DTOs
// ────────────────────────────────────────────
export type DayOfWeek = 1 | 2 | 3 | 4 | 5 | 6 | 7; // ISO 8601: 1=Mon ... 7=Sun
export interface WorkScheduleDayDto {
dayOfWeek: DayOfWeek;
startTime: string; // "HH:MM"
endTime: string; // "HH:MM"
breakStart?: string; // "HH:MM" | null
breakEnd?: string; // "HH:MM" | null
}
export interface CreateWorkScheduleDto {
staffId: string;
effectiveFrom: string; // ISO date "YYYY-MM-DD"
effectiveTo?: string | null; // ISO date | null
days: WorkScheduleDayDto[]; // минимум 1 день
}
export interface UpdateWorkScheduleDto {
effectiveTo?: string | null;
days?: WorkScheduleDayDto[];
}
export interface WorkScheduleResponseDto {
id: string;
staffId: string;
effectiveFrom: string;
effectiveTo: string | null;
timezone: string;
days: WorkScheduleDayDto[];
createdAt: string;
}
// ────────────────────────────────────────────
// Teacher Schedule Exception DTOs
// ────────────────────────────────────────────
export type ScheduleExceptionType = 'vacation' | 'sick_leave' | 'personal' | 'cancelled';
export interface CreateTeacherExceptionDto {
exceptionType: ScheduleExceptionType;
dateFrom: string; // "YYYY-MM-DD"
dateTo: string; // "YYYY-MM-DD"
note?: string;
cancelConflicts?: boolean; // если true — отменить конфликтующие занятия
}
export interface TeacherExceptionResponseDto {
id: string;
exceptionType: ScheduleExceptionType;
dateFrom: string;
dateTo: string;
note: string | null;
conflictingSessions: number; // количество конфликтующих занятий
createdAt: string;
}
// ────────────────────────────────────────────
// Study Group DTOs
// ────────────────────────────────────────────
export interface GroupSchedulePatternDto {
dayOfWeek: DayOfWeek;
startTime: string; // "HH:MM"
durationMinutes: number;
}
export interface CreateStudyGroupDto {
itemId: string;
staffId: string;
name: string; // max 200 символов
maxCapacity: number; // >= 1
room?: string;
startDate: string; // "YYYY-MM-DD"
endDate?: string | null;
schedulePattern: GroupSchedulePatternDto[]; // минимум 1 день
isPublic?: boolean; // default: false
}
export interface UpdateStudyGroupDto {
name?: string;
maxCapacity?: number;
room?: string;
endDate?: string | null;
isPublic?: boolean;
status?: 'active' | 'paused' | 'completed';
}
export interface StudyGroupSummaryDto {
id: string;
name: string;
itemId: string;
itemName: string;
staffId: string;
staffName: string;
maxCapacity: number;
currentEnrollment: number; // COUNT активных записей
status: 'active' | 'paused' | 'completed' | 'cancelled';
isPublic: boolean;
schedulePattern: GroupSchedulePatternDto[];
startDate: string;
endDate: string | null;
}
// ────────────────────────────────────────────
// Calendar View DTOs
// ────────────────────────────────────────────
export type CalendarViewMode = 'day' | 'week' | 'month';
export interface CalendarEventDto {
id: string;
type: 'group_session' | 'individual_booking' | 'exception';
title: string;
date: string; // "YYYY-MM-DD"
startTime: string; // "HH:MM"
endTime: string; // "HH:MM"
staffId: string;
staffName: string;
status: string;
capacity?: { // только для group_session
current: number;
max: number;
};
clientName?: string; // только для individual_booking
groupId?: string;
sessionId?: string;
bookingId?: string;
}
export interface CalendarQueryDto {
mode: CalendarViewMode;
date: string; // опорная дата "YYYY-MM-DD"
staffId?: string; // фильтр по преподавателю
itemId?: string; // фильтр по курсу
}
export interface CalendarResponseDto {
mode: CalendarViewMode;
dateFrom: string;
dateTo: string;
events: CalendarEventDto[];
}
// ────────────────────────────────────────────
// Free Slots DTOs (для онлайн-записи 1:1)
// ────────────────────────────────────────────
export interface FreeSlotsQueryDto {
itemId: string;
staffId?: string; // если не указан — показать все доступные учителя
dateFrom: string; // "YYYY-MM-DD"
dateTo: string; // "YYYY-MM-DD", max диапазон 30 дней
}
export interface FreeSlotDto {
date: string; // "YYYY-MM-DD"
startTime: string; // "HH:MM"
endTime: string; // "HH:MM"
staffId: string;
staffName: string;
}
export interface FreeSlotsResponseDto {
itemId: string;
durationMinutes: number;
slots: FreeSlotDto[];
}
// ────────────────────────────────────────────
// Attendance DTOs
// ────────────────────────────────────────────
export type AttendanceStatus = 'present' | 'absent' | 'excused' | 'late';
export interface AttendanceEntryDto {
enrollmentId: string;
status: AttendanceStatus;
note?: string;
}
export interface MarkAttendanceDto {
records: AttendanceEntryDto[];
sessionNote?: string;
}
export interface AttendanceResponseDto {
sessionId: string;
status: 'completed';
summary: {
total: number;
present: number;
absent: number;
excused: number;
late: number;
};
}
// ────────────────────────────────────────────
// Individual Booking DTOs
// ────────────────────────────────────────────
export interface CreateIndividualBookingDto {
itemId: string;
staffId: string;
clientId: string;
bookingDate: string; // "YYYY-MM-DD"
startTime: string; // "HH:MM"
note?: string;
}
export interface IndividualBookingResponseDto {
id: string;
itemId: string;
itemName: string;
staffId: string;
staffName: string;
clientId: string;
clientName: string;
bookingDate: string;
startTime: string;
endTime: string;
status: IndividualBookingStatus;
source: BookingSource;
note: string | null;
createdAt: string;
}
API Endpoints
Рабочий график преподавателя
GET /api/crm/staff/:staffId/schedule
→ 200 WorkScheduleResponseDto | null
Заголовок: Authorization: Bearer {token}
Guard: CrmJwtGuard + CrmRoleGuard(owner, admin)
POST /api/crm/staff/:staffId/schedule
Body: CreateWorkScheduleDto
→ 201 WorkScheduleResponseDto
Ошибки:
409 { error: "SCHEDULE_OVERLAP", message: "У преподавателя уже есть активный график" }
422 { error: "INVALID_TIMES", field: "days[n]", message: "..." }
PUT /api/crm/staff/:staffId/schedule/:scheduleId
Body: UpdateWorkScheduleDto
→ 200 WorkScheduleResponseDto
Ошибки:
404 { error: "NOT_FOUND" }
403 { error: "FORBIDDEN" }
DELETE /api/crm/staff/:staffId/schedule/:scheduleId
→ 204 No Content
Ошибки:
409 { error: "SCHEDULE_HAS_SESSIONS",
message: "Нельзя удалить график с активными занятиями",
conflictCount: 5 }
Исключения в графике
GET /api/crm/staff/:staffId/exceptions
Query: ?dateFrom=YYYY-MM-DD&dateTo=YYYY-MM-DD
→ 200 TeacherExceptionResponseDto[]
POST /api/crm/staff/:staffId/exceptions
Body: CreateTeacherExceptionDto
→ 201 TeacherExceptionResponseDto
Ошибки:
409 { error: "EXCEPTION_OVERLAP", message: "Период пересекается с уже добавленным исключением" }
DELETE /api/crm/staff/:staffId/exceptions/:exceptionId
→ 204 No Content
Учебные группы
GET /api/crm/groups
Query: ?sellerId, ?itemId, ?status, ?page=1&limit=20
→ 200 { data: StudyGroupSummaryDto[], total: number }
POST /api/crm/groups
Body: CreateStudyGroupDto
→ 201 StudyGroupSummaryDto
Ошибки:
409 { error: "SCHEDULE_CONFLICT",
message: "Преподаватель уже занят в это время",
conflictWith: "English B1 · среда 10:00-11:00" }
422 { error: "TEACHER_NOT_AVAILABLE",
message: "Преподаватель не работает в выбранные дни/часы" }
403 { error: "CRM_SUBSCRIPTION_REQUIRED" }
GET /api/crm/groups/:groupId
→ 200 StudyGroupSummaryDto + sessions[]
PUT /api/crm/groups/:groupId
Body: UpdateStudyGroupDto
→ 200 StudyGroupSummaryDto
DELETE /api/crm/groups/:groupId
→ 204 No Content
(soft delete: status → 'cancelled', все future sessions → 'cancelled')
Занятия (GroupSession)
GET /api/crm/groups/:groupId/sessions
Query: ?dateFrom, ?dateTo, ?status
→ 200 { data: GroupSessionDto[], total: number }
PATCH /api/crm/groups/:groupId/sessions/:sessionId
Body: { status: 'cancelled', cancellationReason?: string }
→ 200 GroupSessionDto
POST /api/crm/groups/:groupId/sessions/:sessionId/attendance
Body: MarkAttendanceDto
→ 200 AttendanceResponseDto
Ошибки:
422 { error: "SESSION_NOT_READY",
message: "Посещаемость можно отметить только в день занятия или после" }
409 { error: "ATTENDANCE_ALREADY_MARKED",
message: "Посещаемость уже отмечена. Используйте PUT для редактирования" }
PUT /api/crm/groups/:groupId/sessions/:sessionId/attendance
Body: MarkAttendanceDto
→ 200 AttendanceResponseDto
Записи в группу (GroupEnrollment)
GET /api/crm/groups/:groupId/enrollments
→ 200 { data: GroupEnrollmentDto[], total: number }
POST /api/crm/groups/:groupId/enrollments
Body: { clientId: string }
→ 201 GroupEnrollmentDto
Ошибки:
409 { error: "GROUP_FULL", message: "Группа заполнена. Максимум {max} учеников." }
409 { error: "ALREADY_ENROLLED", message: "Ученик уже записан в эту группу" }
PATCH /api/crm/groups/:groupId/enrollments/:enrollmentId
Body: { status: 'dropped' | 'paused', dropReason?: string }
→ 200 GroupEnrollmentDto
Сводный CRM-календарь
GET /api/crm/calendar
Query: CalendarQueryDto (mode, date, staffId?, itemId?)
→ 200 CalendarResponseDto
Примечание: Вычисляется на лету. Объединяет GroupSession + IndividualBooking +
TeacherScheduleException за запрошенный период.
Cache: Redis 2 мин (ключ: crm:calendar:{sellerId}:{mode}:{date}:{staffId|all})
Свободные слоты для 1:1 записи
GET /api/crm/slots
Query: FreeSlotsQueryDto (itemId, staffId?, dateFrom, dateTo)
→ 200 FreeSlotsResponseDto
Ограничения:
- max диапазон dateTo-dateFrom = 30 дней
- если staffId не указан — возвращает слоты всех учителей этого seller, ведущих данный item
Ошибки:
422 { error: "DATE_RANGE_TOO_LARGE", message: "Максимальный диапазон — 30 дней" }
Cache: Redis 1 мин (ключ: crm:slots:{sellerId}:{itemId}:{staffId|all}:{dateFrom}:{dateTo})
Примечание: НЕ хранит слоты в БД. Вычисляет разницу WorkSchedule - GroupSessions - IndividualBookings.
Индивидуальные записи (1:1)
GET /api/crm/bookings
Query: ?staffId, ?clientId, ?dateFrom, ?dateTo, ?status, ?page=1&limit=20
→ 200 { data: IndividualBookingResponseDto[], total: number }
POST /api/crm/bookings
Body: CreateIndividualBookingDto
→ 201 IndividualBookingResponseDto
Ошибки:
409 { error: "SLOT_TAKEN", message: "Выбранный слот уже занят" }
409 { error: "OUTSIDE_WORKING_HOURS", message: "Время находится вне рабочего графика преподавателя" }
409 { error: "EXCEPTION_CONFLICT", message: "В этот день у преподавателя исключение: {тип}" }
GET /api/crm/bookings/:bookingId
→ 200 IndividualBookingResponseDto
PATCH /api/crm/bookings/:bookingId
Body: { status?: IndividualBookingStatus, cancelReason?: string }
→ 200 IndividualBookingResponseDto
Публичные слоты (для карточки айтема, без авторизации)
GET /api/public/items/:itemId/schedule
→ 200 {
type: 'groups' | 'slots',
// если groups:
groups?: PublicGroupDto[], // {id, name, schedule, currentEnrollment, maxCapacity}
// если slots:
availableDates?: string[], // ближайшие 14 дней с хотя бы одним слотом
}
Примечание: Возвращает данные только если seller.crm_subscription.status = active
AND studyGroup.isPublic = true (для групп)
Cache: Redis 5 мин
7. Edge Cases
E-01: Смена преподавателя в середине семестра
Ситуация: Owner хочет заменить преподавателя в группе.
Поведение: Смена преподавателя в StudyGroup.staffId применяется только к будущим сессиям. Прошедшие сессии (и их посещаемость) сохраняют старого преподавателя. Система логирует смену с датой и ответственным.
E-02: Изменение расписания активной группы
Ситуация: Нужно перенести группу с пятницы 10:00 на пятницу 11:00 начиная со следующей недели.
Поведение: GroupSchedule.effectiveTo = следующая пятница - 1 день. Создаётся новый GroupSchedule с effectiveFrom = следующая пятница. Все уже сгенерированные GroupSession в прошлом не изменяются. Будущие сессии пересоздаются по новому паттерну.
E-03: Преподаватель уволился
Ситуация: SellerStaff переведён в статус inactive.
Поведение: Все его будущие GroupSession и IndividualBooking помечаются предупреждением, но не отменяются автоматически. Owner получает уведомление: "Преподаватель {имя} деактивирован. Назначьте замену для {N} групп и {M} записей."
E-04: Один преподаватель, несколько учреждений
Ситуация: В v1.0 не поддерживается. SellerStaff привязан к одному Seller.
Поведение: При попытке добавить одного и того же человека к двум организациям — создаются два разных SellerStaff (разные аккаунты).
E-05: Изменение max_capacity в сторону уменьшения
Ситуация: В группе 8 учеников, Owner хочет уменьшить лимит до 5.
Поведение: Система запрещает уменьшение ниже текущего current_enrollment.
Ошибка: 409 { error: "CAPACITY_BELOW_ENROLLMENT", message: "Нельзя установить лимит меньше текущего числа учеников (8)" }
E-06: Запрос свободных слотов для неактивного CRM
Ситуация: Покупатель заходит на карточку айтема у продавца с истёкшей CRM-подпиской.
Поведение: GET /api/public/items/:itemId/schedule возвращает { type: 'lead_only' } — кнопка "Записаться" остаётся, но показывает обычную лид-форму (MVP поведение), а не расписание.
E-07: Конкурентное бронирование одного слота
Ситуация: Два покупателя одновременно пытаются забронировать последнее место в группе или один слот у репетитора.
Поведение: Использовать транзакцию с SELECT ... FOR UPDATE при создании booking. Второй запрос получает 409 SLOT_TAKEN. В публичном API кешируем слоты на 1 минуту, допускаем race condition — клиент получит ошибку и может выбрать другой слот.
E-08: CRM-подписка истекает в середине месяца
Ситуация: Продавец не продлил подписку. Поведение:
- Существующие данные (группы, расписания, история) сохраняются — не удаляются
- Новые бронирования с публичной карточки недоступны
- CRM-интерфейс недоступен (
/seller/crm/*→ редирект на биллинг) - При возобновлении подписки — все данные доступны снова
E-09: Занятие длиннее рабочего дня
Ситуация: Попытка создать занятие длительностью 3 часа при рабочем дне 2 часа.
Поведение: 422 { error: "SESSION_EXCEEDS_WORKDAY" } при создании группы.
E-10: Генерация сессий на праздничные дни
Ситуация: Школа не работает в государственные праздники, но сессии генерируются. Поведение: В v1.0 праздники не обрабатываются автоматически. Owner должен вручную добавить исключение в график преподавателя. Публичный список праздников UZ — TBD для v1.5.
8. TBD (открытые вопросы)
| # | Вопрос | Срок |
|---|---|---|
| TBD-01 | Интеграция с CrmClient — формат clientId в записях. Spec v1-02 ещё не написан. Нужно согласовать FK. | До начала реализации Spec v1-02 |
| TBD-02 | Уведомления об отмене занятия — через какой канал? Telegram? Email? Какой шаблон сообщения? | До интеграции с уведомлениями |
| TBD-03 | Список ожидания (waitlist) — в v1.0 не реализуется. В v1.5 нужна модель GroupWaitlist. | v1.5 |
| TBD-04 | Государственные праздники Узбекистана — автоматическая блокировка дат или справочник для владельца? | v1.5 |
| TBD-05 | Изменение расписания группы с уведомлениями — нужен flow "перенос занятия" с автоматической рассылкой всем записанным ученикам. | v1.5 |
| TBD-06 | Экспорт расписания — выгрузка в iCal / Google Calendar / Excel. Нужен ли в v1.0? | TBD |
| TBD-07 | Recurring exceptions — повторяющиеся исключения (например, каждый первый понедельник месяца). В v1.0 только диапазоны дат. | v1.5 |
| TBD-08 | Автоматическое завершение IndividualBooking — cron на перевод confirmed → completed через N минут после endTime? Или вручную? | До реализации bookings |
| TBD-09 | Multi-room support — у крупной школы несколько кабинетов. Как управлять конфликтами аудиторий? | v2.0 |
| TBD-10 | Teacher self-service — могут ли учителя самостоятельно редактировать свой рабочий график (без Owner/Admin)? Сейчас запрещено. | После первого раунда user testing |
| TBD-11 | Timezone per teacher — в v1.0 единый timezone = Asia/Tashkent. При расширении в v1.5 (другие города/страны) нужен per-teacher timezone. | v1.5 |
| TBD-12 | Horizon for session generation — сейчас 60 дней. Оптимально? При изменении нужно пересмотреть cron-job и индексы. | Benchmarking после первых 100 школ |