# v1.0 Spec 01 — CRM: Calendar & Schedule

## Паспорт документа

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> 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

```prisma
// ─────────────────────────────────────────────────────
// Рабочий график преподавателя
// ─────────────────────────────────────────────────────

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

```typescript
// ────────────────────────────────────────────
// 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 школ |
