# CRM-модуль MyFitCRM

Универсальный CRM-модуль на базе Entity Factory Pattern (аналог Битрикс24).
Работает на двух уровнях: платформа (B2B — продажа клубам) и клуб (B2C — ведение клиентов).
Разделение через `clubId`: `null` = платформа, UUID = конкретный клуб.

## 1. Entity Factory Pattern (ядро системы)

Все сущности (сделки, тренировки и будущие) строятся по единому шаблону через фабрику.

### Базовый EntityManager\<T\>

```typescript
abstract class EntityManager<T> {
  add(fields: Partial<T>, context?: EntityContext): Promise<T>;
  update(id: string, fields: Partial<T>, context?: EntityContext): Promise<T>;
  delete(id: string, context?: EntityContext): Promise<void>;
  getById(id: string): Promise<T | null>;
  getList(filter, order, select, limit, offset): Promise<{ data: T[]; total: number }>;
  getCount(filter): Promise<number>;
}
```

### Контекст операции

```typescript
interface EntityContext {
  skipEvents?: boolean;      // отключить события
  skipPermissions?: boolean; // отключить проверку прав
  skipValidation?: boolean;  // отключить валидацию обязательных полей
  userId?: string;           // от чьего имени (для системных операций)
}
```

Примеры использования:
- Массовый импорт без триггеров: `dealManager.add(data, { skipEvents: true })`
- Системные операции: `dealManager.update(id, data, { skipPermissions: true })`
- Миграции: `dealManager.add(data, { skipValidation: true, skipEvents: true })`

### Система событий

```
onBeforeAdd(fields, context)              — модификация fields или выброс исключения
onAfterAdd(entity, context)               — уведомления, логирование, связанные действия
onBeforeUpdate(id, fields, context)       — валидация переходов стадий, бизнес-правила
onAfterUpdate(entity, oldEntity, context) — пересчёт, уведомления
onBeforeDelete(id, context)               — проверка зависимостей
onAfterDelete(id, context)                — очистка связей, уведомления
```

### Регистрация сущностей

```typescript
EntityRegistry.register('deal', {
  table: 'crm_deal',
  entityManager: DealManager,
  permissions: DealPermissions,
  customFieldsEnabled: true,
  timelineEnabled: true,
  defaultFields: [...]
});

EntityRegistry.register('training', {
  table: 'crm_training',
  entityManager: TrainingManager,
  permissions: TrainingPermissions,
  customFieldsEnabled: true,
  timelineEnabled: false,
  defaultFields: [...]
});
```

Добавить новую сущность = зарегистрировать в фабрике + описать поля. CRUD, события, права, кастомные поля — из коробки.

### Пример подписки на события

```typescript
// При выигрыше сделки → автосоздание клуба
dealManager.on('onAfterUpdate', async (deal, oldDeal, ctx) => {
  if (deal.stageType === 'WON' && oldDeal.stageType !== 'WON') {
    await provisioningService.createClub(deal, { skipEvents: false });
  }
});

// При создании тренировки → запись в таймлайн сделки
trainingManager.on('onAfterAdd', async (training, ctx) => {
  if (training.dealId) {
    await timelineService.add(training.dealId, {
      type: 'TRAINING_CREATED',
      data: training
    });
  }
});
```

---

## 2. Модель данных

### CrmPipeline (Воронка)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| clubId | UUID? | null = платформа, uuid = клуб |
| name | String | Название воронки |
| isDefault | Boolean | Воронка по умолчанию |
| isActive | Boolean | Активна |
| createdAt | DateTime | |

### CrmStage (Стадия)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| pipelineId | UUID FK | → CrmPipeline |
| name | String | Название стадии |
| position | Int | Порядок (для сортировки и drag-n-drop) |
| color | String | HEX-цвет (#3B82F6) |
| type | Enum | OPEN / WON / LOST |
| autoActivityType | Enum? | CALL / MEETING — автосоздание дела при попадании |
| autoActivityDays | Int? | Через N дней создать авто-дело |
| staleDays | Int? | Через сколько дней считать сделку застоявшейся |
| isDefault | Boolean | Новые сделки попадают сюда |

### CrmDeal (Сделка) — основная сущность

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| clubId | UUID? | null = платформа, uuid = клуб |
| pipelineId | UUID FK | → CrmPipeline |
| stageId | UUID FK | → CrmStage |
| assigneeId | UUID FK | → User (ответственный менеджер) |
| title | String | Название сделки |
| amount | Decimal? | Сумма |
| currency | String | По умолчанию 'RUB' |
| **Контакт** | | |
| contactName | String | ФИО |
| contactPhone | String? | Телефон |
| contactEmail | String? | Email |
| contactTelegram | String? | Telegram (username или phone) |
| contactWhatsapp | String? | WhatsApp (номер) |
| contactVk | String? | ВКонтакте (id или ссылка) |
| **Реквизиты** | | |
| companyName | String? | Название организации |
| companyInn | String? | ИНН |
| companyKpp | String? | КПП |
| companyOgrn | String? | ОГРН |
| companyLegalAddress | String? | Юридический адрес |
| companyBankAccount | String? | Расчётный счёт |
| companyBik | String? | БИК |
| companyBankName | String? | Банк |
| **Служебное** | | |
| source | Enum | FORM / MANUAL / IMPORT / API / WEBHOOK |
| probability | Int? | 0-100 (вероятность закрытия) |
| expectedCloseDate | DateTime? | Ожидаемая дата закрытия |
| closedAt | DateTime? | Фактическая дата закрытия |
| lostReasonId | UUID? FK | → CrmLostReason |
| lostComment | String? | Комментарий к проигрышу |
| metadata | JSON? | UTM, аналитика, rawFields, originalPayload |
| createdAt | DateTime | |
| updatedAt | DateTime | |
| createdById | UUID FK | → User |

### CrmTraining (Тренировка — привязана к сделке)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| clubId | UUID? | |
| dealId | UUID FK | → CrmDeal |
| trainerId | UUID? FK | → User |
| clientName | String | |
| type | Enum | PERSONAL / GROUP / TRIAL / SPLIT |
| status | Enum | PLANNED / COMPLETED / CANCELLED / NO_SHOW |
| scheduledAt | DateTime | Время тренировки |
| duration | Int | Длительность в минутах |
| notes | String? | Заметки |
| createdAt | DateTime | |
| updatedAt | DateTime | |

### CrmTimeline (Таймлайн сделки — лента как в Битрикс24)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| dealId | UUID FK | → CrmDeal |
| userId | UUID? FK | → User (автор) |
| type | Enum | См. типы карточек ниже |
| subject | String? | Заголовок карточки |
| content | String? | Текст |
| metadata | JSON | Доп. данные в зависимости от типа |
| pinnedAt | DateTime? | Закреплённая запись |
| createdAt | DateTime | |

**Типы карточек таймлайна:**

| Тип | Описание | metadata |
|-----|----------|----------|
| FORM_SUBMISSION | Заявка с формы | source, utm, ip, formData |
| CALL_INCOMING | Входящий звонок | phone, duration, recordUrl, status |
| CALL_OUTGOING | Исходящий звонок | phone, duration, recordUrl, status |
| COMMENT | Комментарий менеджера | mentions?: userId[] |
| MESSAGE_TG | Сообщение в Telegram | chatId, messageId, direction: in/out |
| MESSAGE_WA | Сообщение в WhatsApp | phone, messageId, direction |
| MESSAGE_VK | Сообщение ВКонтакте | peerId, messageId, direction |
| MESSAGE_EMAIL | Email | from, to, subject, trackOpened |
| STAGE_CHANGE | Смена стадии | fromStage, toStage |
| ACTIVITY_CREATED | Запланировано дело | activityType, scheduledAt |
| ACTIVITY_DONE | Дело выполнено | activityType, result |
| TRAINING_CREATED | Тренировка назначена | trainingId, type, scheduledAt |
| SYSTEM | Системное событие | action, description |

Карточки звонков содержат `recordUrl` — player прямо в карточке таймлайна.

### CrmActivity (Дела — запланированные действия)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| dealId | UUID FK | → CrmDeal |
| assigneeId | UUID FK | → User |
| type | Enum | CALL / MEETING / EMAIL / MESSAGE / CUSTOM |
| subject | String | Заголовок |
| description | String? | Описание |
| scheduledAt | DateTime | Когда запланировано |
| deadline | DateTime? | Крайний срок |
| completedAt | DateTime? | Дата выполнения |
| result | String? | Результат (заполняется при выполнении) |
| channel | Enum? | TG / WHATSAPP / VK / EMAIL (для типа MESSAGE) |
| createdAt | DateTime | |

Логика дел:
- При создании → карточка в таймлайн ACTIVITY_CREATED
- При выполнении → карточка ACTIVITY_DONE + result
- Просроченные дела → напоминание ответственному
- Дела отображаются в карточке сделки + в общем календаре менеджера

### CrmStageHistory (История перемещений)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| dealId | UUID FK | → CrmDeal |
| fromStageId | UUID? FK | → CrmStage (null при создании) |
| toStageId | UUID FK | → CrmStage |
| movedById | UUID? FK | → User |
| duration | Int? | Время в секундах на предыдущей стадии |
| movedAt | DateTime | |

### CrmLostReason (Причины отказа — справочник)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| clubId | UUID? | |
| name | String | "Дорого", "Ушёл к конкуренту", "Не актуально"... |
| position | Int | Порядок |
| isActive | Boolean | |

---

## 3. Кастомные поля

### CrmUserField

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| clubId | UUID? | null = глобальное, uuid = поле клуба |
| entityType | String | 'deal' / 'training' / ... |
| name | String | Отображаемое название ("Дата пробной") |
| fieldName | String | Имя в базе, латиница ("trial_date") |
| type | Enum | STRING / INTEGER / FLOAT / LIST / BOOLEAN / DATE / DATETIME / TIME |
| listOptions | JSON? | Для LIST: ["Вариант 1", "Вариант 2"] |
| isRequired | Boolean | По умолчанию false |
| isMultiple | Boolean | Множественный выбор для LIST |
| showToTrainer | Boolean | Показывать тренеру в карточке клиента |
| position | Int | Порядок отображения (drag-n-drop) |
| defaultValue | String? | Значение по умолчанию |
| description | String? | Подсказка |
| isActive | Boolean | |
| createdAt | DateTime | |

### CrmUserFieldValue

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| fieldId | UUID FK | → CrmUserField |
| entityId | UUID | ID сделки/тренировки/... |
| entityType | String | 'deal' / 'training' / ... |
| value | String | Сериализованное значение |

### Настройки кастомных полей в UI

| Параметр | Описание |
|----------|----------|
| Название | "Дата первого визита" |
| Имя в базе | first_visit_date (латиница, авто-slug) |
| Тип | STRING / INTEGER / FLOAT / LIST / BOOLEAN / DATE / DATETIME / TIME |
| Обязательное | Да / Нет |
| Показывать тренеру | Да / Нет |
| Значение по умолчанию | — |
| Порядок | 1, 2, 3... (drag & drop) |

Каждый клуб настраивает свои поля. Платформа — свои.

---

## 4. Стадии по умолчанию

### B2B Pipeline (Продажа клубам)

| # | Стадия | Цвет | Тип | Авто-действие |
|---|--------|------|-----|---------------|
| 1 | Новый лид | #3B82F6 (blue) | OPEN | Задача: "Связаться с клиентом" |
| 2 | Квалификация | #8B5CF6 (violet) | OPEN | Напоминание: 2 дня |
| 3 | Демонстрация | #F59E0B (amber) | OPEN | Задача: "Назначить демо" |
| 4 | Коммерческое предложение | #EF4444 (red) | OPEN | Напоминание: 3 дня |
| 5 | Согласование | #EC4899 (pink) | OPEN | Напоминание: 5 дней |
| 6 | Оплата | #14B8A6 (teal) | OPEN | — |
| 7 | Выиграна | #22C55E (green) | WON | Задача: "Провижинг клуба" |
| 8 | Проиграна | #6B7280 (gray) | LOST | — |

### B2C Pipeline (Клуб → клиенты)

| # | Стадия | Цвет | Тип | Авто-действие |
|---|--------|------|-----|---------------|
| 1 | Обращение | #3B82F6 | OPEN | Задача: "Перезвонить" |
| 2 | Первый контакт | #8B5CF6 | OPEN | — |
| 3 | Пробное занятие | #F59E0B | OPEN | Задача: "Назначить пробную" |
| 4 | Предложение | #EF4444 | OPEN | Напоминание: 2 дня |
| 5 | Оплата абонемента | #14B8A6 | OPEN | — |
| 6 | Продано | #22C55E | WON | — |
| 7 | Отказ | #6B7280 | LOST | — |

---

## 5. Интеграция с лендингом + антифрод

### Эндпоинт формы

```
POST /v1/crm/deals/from-form (публичный, без JWT)

Body: {
  name, phone?, email?, message?,
  source: "landing",
  honeypot: "",
  turnstileToken?: "",
  fingerprint?: "",
  formFilledMs: 4500,
  utm_source?, utm_medium?, utm_campaign?
}
```

### Антифрод (5 уровней)

1. **Honeypot** — скрытое поле, боты его заполняют → spam
2. **Rate limit** — макс. 3 заявки с одного IP за 10 мин
3. **Fingerprint** — повтор browser fingerprint за 24ч → +30 баллов
4. **Scoring** — каждый фактор добавляет баллы:
   - Disposable email: +40
   - IP из datacenter/VPN: +20
   - Одинаковый fingerprint повторно: +30
   - Заполнено < 2 секунд: +50 (бот)
   - Невалидный телефон: +30
   - Порог: score >= 70 → автоматически SPAM
5. **Captcha** — Turnstile/hCaptcha при score 40-69

### Флоу обработки

```
[Landing Form]
  → POST /v1/crm/deals/from-form (с антифрод-проверками)
  → spamScore < 40 → создаётся CrmDeal через фабрику (с событиями!)
    → стадия: "Новый лид" дефолтного pipeline
    → карточка FORM_SUBMISSION в таймлайн
    → EventEmitter: 'crm.deal.created'
    → Push-уведомление менеджерам
    → Авто-задача "Связаться с клиентом"
```

---

## 6. Универсальный webhook для внешних заявок

### Эндпоинт

```
POST /v1/crm/deals/webhook/:token (публичный, без JWT)

token — уникальный ключ на клуб/воронку (генерируется в настройках CRM).
Принимает: application/json, application/x-www-form-urlencoded, multipart/form-data
```

### Что парсим из запроса

**Из headers:**
- Referer (откуда пришла заявка)
- X-Forwarded-For / CF-Connecting-IP (IP клиента)
- User-Agent
- Cookie (если прокидываются)

**UTM-метки (из body, query params или cookie):**
- utm_source, utm_medium, utm_campaign, utm_content, utm_term

**Аналитика сквозная (из body, query params или cookie):**
- Яндекс Метрика: `_ym_uid`, `_ym_counter`
- Яндекс Директ: `yclid`
- Google Analytics: `_ga`, `_gid`
- Google Ads: `gclid`
- Facebook: `fbclid`
- Roistat: `roistat_visit`
- Calltouch: `calltouch_session`
- CoMagic: `comagic_id`
- Любые другие кастомные идентификаторы

Всё найденное сохраняется в `metadata` JSON поле сделки. Оригинальный payload тоже сохраняется целиком в `metadata.originalPayload`.

### Smart Field Detection (автопарсинг полей)

**Приоритет 1 — Настроенный маппинг (FieldMapping):**
Если для webhook-токена настроено соответствие полей — используем его.

**Приоритет 2 — Распознавание по имени поля:**
- Телефон: phone, tel, telephone, mobile, contact_phone, phone_number, Телефон, телефон, Тел...
- Имя: name, first_name, firstname, fio, client_name, Имя, имя, ФИО, full_name...
- Email: email, mail, e-mail, e_mail, Почта, электронная_почта, contact_email...

**Приоритет 3 — Распознавание по значению (regex):**
- Телефон: +7/8 + 10 цифр, (xxx) xxx-xx-xx
- Email: xxx@xxx.xx
- Имя: 2-3 слова кириллицей с заглавной

**Приоритет 4 — Нераспознанное:**
Складывается в `metadata.rawFields`.

### Маппинг полей (настраивается в UI)

В настройках вебхука менеджер/админ задаёт соответствие:

```
Внешнее поле         → Поле сделки
"user_tel"           → contactPhone
"client_fio"         → contactName
"user_email"         → contactEmail
"Комментарий"        → title
"tariff_id"          → кастомное поле "Тариф" (LIST)
"city"               → кастомное поле "Город" (STRING)
```

### Маппинг значений списочных полей

Если внешняя система отправляет ID значения (например `tariff_id: "3"`), а в CRM кастомное поле типа LIST с вариантами ["Стартер", "Стандарт", "Премиум"] — настраивается таблица соответствия:

```
Внешний ID "1" → "Стартер"
Внешний ID "2" → "Стандарт"
Внешний ID "3" → "Премиум"
```

Также поддерживается автосопоставление по текстовому значению: если пришло "Стандарт" или "standard" — ищем ближайшее совпадение (case-insensitive + транслитерация).

### CrmWebhookEndpoint (модель данных)

| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID PK | |
| clubId | UUID? | |
| pipelineId | UUID FK | → CrmPipeline (в какую воронку) |
| stageId | UUID FK | → CrmStage (на какую стадию) |
| token | String | Уникальный, используется в URL |
| name | String | "Заявки с Tilda", "Лиды из JivoSite"... |
| isActive | Boolean | |
| fieldMappings | JSON | Маппинг полей |
| listMappings | JSON | Маппинг значений списков |
| defaultAssigneeId | UUID? FK | → User (кому назначать) |
| antifraudEnabled | Boolean | Включить антифрод |
| createdAt | DateTime | |

### CrmDeal.metadata (JSON) — структура

```json
{
  "utm": {
    "source": "yandex",
    "medium": "cpc",
    "campaign": "fitness_msk"
  },
  "analytics": {
    "ym_uid": "123456789",
    "ga": "GA1.2.xxx",
    "yclid": "abc123",
    "gclid": "def456",
    "roistat_visit": "789"
  },
  "referer": "https://landing.club.ru/promo",
  "ip": "1.2.3.4",
  "userAgent": "Mozilla/5.0...",
  "cookies": {
    "_ym_uid": "123",
    "_ga": "GA1.2.xxx"
  },
  "rawFields": {
    "custom_field_1": "value"
  },
  "webhookId": "uuid",
  "originalPayload": { ... }
}
```

---

## 7. Мультиканальные сообщения

В карточке сделки менеджер видит доступные каналы связи на основе заполненных данных:

| Поле в сделке | Канал | Действие |
|---------------|-------|----------|
| contactTelegram | Telegram | Открыть чат / отправить сообщение |
| contactWhatsapp | WhatsApp | Открыть wa.me/... или API |
| contactVk | ВКонтакте | Отправить сообщение через VK API |
| contactEmail | Email | Отправить письмо из CRM |
| contactPhone | Звонок | Click-to-call через SIP |

Каждое отправленное/полученное сообщение → карточка в таймлайн (MESSAGE_TG, MESSAGE_WA, MESSAGE_VK, MESSAGE_EMAIL).

Входящие сообщения — через вебхуки от мессенджеров (отдельная интеграция, архитектура готова).

---

## 8. Напоминания и дела

### Автоматические напоминания

| Триггер | Когда | Кому |
|---------|-------|------|
| Сделка застряла на стадии | N дней (staleDays в настройках стадии) | Ответственный менеджер |
| Дедлайн задачи | За 1 час / в момент дедлайна | Исполнитель |
| Истекает лицензия | За 30 / 14 / 7 / 1 день | Менеджер сделки |
| Ожидаемая дата закрытия | За 3 дня / при просрочке | Менеджер |
| Новый лид не обработан | Через 30 мин | Менеджер / все менеджеры |
| День рождения контакта | В день | Менеджер |

### Ручные дела

- Менеджер ставит дело: "Позвонить 28.02 в 14:00"
- Тип: звонок, встреча, email, сообщение, своё
- Напоминание за N минут до
- После выполнения — результат записывается в таймлайн

### Реализация

BullMQ queue `crm-scheduler` с cron-процессором каждые 5 мин:
- Проверяет сделки с `staleDays` на стадии
- Проверяет задачи с приближающимся `dueDate`
- Проверяет лиды со статусом NEW старше 30 мин

---

## 9. RBAC для CRM

| Действие | super_admin | club_admin | manager | trainer |
|----------|:-----------:|:----------:|:-------:|:-------:|
| Настройка pipeline/стадий | + | + (свой клуб) | — | — |
| Настройка кастомных полей | + | + (свой клуб) | — | — |
| Создание сделок | + | + | + | — |
| Просмотр всех сделок | + | + | + (только свои) | — |
| Таймлайн / комментарии | + | + | + (свои сделки) | — |
| Дела (задачи) | + | + | + (свои) | — |
| Просмотр карточки клиента | + | + | + | + (только поля с showToTrainer) |
| Удаление сделок | + | + | — | — |
| Аналитика CRM | + | + | ограниченно | — |

---

## 10. API-эндпоинты

### Сделки (через EntityManager)

```
GET    /v1/crm/deals                    — список + фильтры + kanban-view
GET    /v1/crm/deals/:id               — карточка + таймлайн + tasks
POST   /v1/crm/deals                   — создать
PATCH  /v1/crm/deals/:id               — обновить
DELETE /v1/crm/deals/:id               — удалить (soft)
POST   /v1/crm/deals/:id/move          — смена стадии
POST   /v1/crm/deals/:id/win           — выиграть
POST   /v1/crm/deals/:id/lose          — проиграть { lostReasonId, comment }
POST   /v1/crm/deals/from-form         — с формы лендинга (публичный)
POST   /v1/crm/deals/webhook/:token    — с внешних систем (публичный)
```

### Таймлайн

```
GET    /v1/crm/deals/:id/timeline      — лента сделки
POST   /v1/crm/deals/:id/timeline      — добавить комментарий/запись
```

### Дела (активности)

```
GET    /v1/crm/activities               — все дела менеджера (+ фильтры по дате)
GET    /v1/crm/deals/:id/activities    — дела по сделке
POST   /v1/crm/activities              — создать дело
PATCH  /v1/crm/activities/:id          — обновить
POST   /v1/crm/activities/:id/complete — выполнить (с result)
```

### Тренировки (через EntityManager)

```
GET    /v1/crm/trainings               — список
POST   /v1/crm/trainings              — создать (привязка к сделке)
PATCH  /v1/crm/trainings/:id          — обновить
DELETE /v1/crm/trainings/:id          — удалить
```

### Сообщения

```
POST   /v1/crm/deals/:id/message      — отправить (channel: tg|wa|vk|email)
```

### Pipeline и стадии

```
GET    /v1/crm/pipelines               — список воронок
POST   /v1/crm/pipelines              — создать
PATCH  /v1/crm/pipelines/:id          — обновить
GET    /v1/crm/pipelines/:id/stages   — стадии воронки
POST   /v1/crm/pipelines/:id/stages   — создать стадию
PATCH  /v1/crm/stages/:id             — обновить стадию
PUT    /v1/crm/stages/reorder          — пересортировка
```

### Кастомные поля

```
GET    /v1/crm/fields?entityType=deal  — список полей
POST   /v1/crm/fields                 — создать поле
PATCH  /v1/crm/fields/:id             — обновить (название, showToTrainer...)
DELETE /v1/crm/fields/:id             — удалить поле
```

### Webhook-эндпоинты (управление)

```
GET    /v1/crm/webhooks                — список эндпоинтов
POST   /v1/crm/webhooks               — создать
PATCH  /v1/crm/webhooks/:id           — обновить (маппинг, pipeline...)
DELETE /v1/crm/webhooks/:id           — удалить
GET    /v1/crm/webhooks/:id/test      — тестовая заявка
GET    /v1/crm/webhooks/:id/log       — лог последних заявок
```

### Причины отказа

```
GET    /v1/crm/lost-reasons            — список
POST   /v1/crm/lost-reasons           — создать
PATCH  /v1/crm/lost-reasons/:id       — обновить
```

### Аналитика

```
GET    /v1/crm/analytics/funnel        — конверсия по стадиям
GET    /v1/crm/analytics/forecast      — прогноз выручки
GET    /v1/crm/analytics/performance   — по менеджерам
GET    /v1/crm/analytics/cycle         — средний цикл сделки
```

---

## 11. Структура файлов модуля

```
apps/api/src/modules/crm/
├── crm.module.ts
├── core/
│   ├── entity-manager.base.ts          — базовый EntityManager<T>
│   ├── entity-registry.ts              — реестр сущностей
│   ├── entity-context.interface.ts     — EntityContext (skipEvents, skipPermissions...)
│   ├── entity-events.service.ts        — шина событий onBefore/onAfter
│   └── entity-permissions.base.ts      — базовая проверка прав
├── entities/
│   ├── deal/
│   │   ├── deal.manager.ts             — extends EntityManager<Deal>
│   │   ├── deal.permissions.ts
│   │   └── deal.events.ts              — подписки на события
│   └── training/
│       ├── training.manager.ts         — extends EntityManager<Training>
│       ├── training.permissions.ts
│       └── training.events.ts
├── controllers/
│   ├── deals.controller.ts
│   ├── trainings.controller.ts
│   ├── activities.controller.ts
│   ├── timeline.controller.ts
│   ├── pipelines.controller.ts
│   ├── fields.controller.ts
│   ├── webhooks.controller.ts
│   └── analytics.controller.ts
├── services/
│   ├── timeline.service.ts
│   ├── activities.service.ts
│   ├── user-fields.service.ts
│   ├── fraud-detection.service.ts
│   ├── webhook-processor.service.ts    — обработка входящих вебхуков
│   ├── field-detector.service.ts       — Smart Field Detection
│   ├── messaging.service.ts            — TG/WA/VK/Email отправка
│   └── analytics.service.ts
├── consumers/
│   └── crm-scheduler.consumer.ts      — BullMQ: напоминания, staleDays
├── dto/
│   ├── create-deal.dto.ts
│   ├── update-deal.dto.ts
│   ├── move-deal.dto.ts
│   ├── create-activity.dto.ts
│   ├── complete-activity.dto.ts
│   ├── create-training.dto.ts
│   ├── create-field.dto.ts
│   ├── create-webhook.dto.ts
│   ├── webhook-payload.dto.ts
│   └── index.ts
└── index.ts
```
