From f6d8e3b6c8df20a335123e6b272d9c1ec335d1df Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Fri, 29 May 2026 16:08:56 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20SCHEMA.md=20v1.1=20=E2=80=94=20=D1=83?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20CRM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mokap/SCHEMA.md | 528 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/SCHEMA.md | 528 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1056 insertions(+) create mode 100644 Mokap/SCHEMA.md create mode 100644 docs/SCHEMA.md diff --git a/Mokap/SCHEMA.md b/Mokap/SCHEMA.md new file mode 100644 index 0000000..cf9890e --- /dev/null +++ b/Mokap/SCHEMA.md @@ -0,0 +1,528 @@ +# SCHEMA.md — Контракт данных CRM мебельного салона + +**Проект:** CRM для сети мебельных салонов (РФ) +**Версия:** 1.1 · 2026-05-29 +**Реализация:** JavaScript, in-memory в `data.js` (в перспективе — `localStorage`). Бэкенда в первом прототипе нет. +**Соглашения:** поля — `camelCase` (английские); id — строки вида `'ord_001'`, `'cli_001'`, `'usr_001'`. + +Этот документ — **единственный источник истины** по структуре данных. Если экран показывает поле, которого здесь нет, — прав документ, а не экран. Если разработчику что-то непонятно — это баг документа, заведите вопрос, не додумывайте. + +--- + +## 0. Что этот контракт чинит (из аудита) + +| Проблема аудита | Решение в схеме | +|---|---| +| Заказы и клиенты не существуют в общем источнике | Сущности `orders`, `clients` в `data.js` | +| Клиент — строка, не сущность | `clients[]` с `id`, заказы ссылаются `clientId` | +| KPI захардкожены | KPI вынесены в раздел 5 «Вычисляемые поля», не хранятся | +| Связи по именам | Все связи через `id` (раздел 2) | +| 3 формата времени | Единый стандарт: ISO 8601 (раздел 6, БП-1) | +| Enum как свободные строки | Все enum зафиксированы в `_DICT` (раздел 3) | +| Комиссии и конфиги в вёрстке | Сущность `config` + `commissionRules` (раздел 1.7) | +| id менеджера по инициалам (`'ak'`) | `id` = `'usr_001'` (стабильный), инициалы → поле `initials` | +| Оценки сотрудников нигде не хранятся / непрозрачны | Сущность `ratings` (раздел 1.8), рейтинг вычисляется (раздел 5), анонимность для оцениваемого (БП-14) | +| Ставка комиссии захардкожена одним числом | Трёхуровневый приоритет: `orders.commissionRate` → `users.commissionRate` → `config.commissionRules` (БП-5) | + +--- + +## 1. Сущности (хранимые) + +Порядок объявления в `data.js`: `config` → `salons` → `users` → `clients` → `orders` → `appointments` → `requests` → `shiftRequests` → `ratings`. Сущность не может ссылаться на ещё не объявленную — отсюда порядок. + +### 1.1 `salons` — салоны сети + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'sal_lenina'` | Первичный ключ. Префикс `sal_`. Неизменяемый. | +| `name` | string | да | `'Салон на Ленина'` | Отображаемое название. | +| `address` | string | да | `'пр. Ленина, 42'` | Физический адрес. | +| `color` | string | да | `'#3B82F6'` | HEX-цвет для UI (графики, бейджи салона). | +| `adminId` | string\|null | да | `'usr_002'` | FK → `users.id`. Директор салона. `null` если вакансия. | +| `revenuePlan` | number | да | `1700000` | План выручки на текущий месяц, ₽. Целое. | +| `ordersPlan` | number | да | `30` | План по кол-ву заказов на месяц. Целое. | +| `active` | boolean | да | `true` | `false` — салон закрыт/заморожен, скрыт из активных списков. | + +> **Не хранить здесь:** `orders`, `revenue`, `overdue`, `newLeads` — это факт, вычисляется из `orders` (раздел 5). + +### 1.2 `users` — сотрудники (все роли) + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'usr_001'` | Первичный ключ. Префикс `usr_` + порядковый номер. **Никогда не основан на инициалах** (коллизии). Неизменяемый. | +| `name` | string | да | `'Анна Кузнецова'` | Полное имя. | +| `initials` | string | да | `'АК'` | Инициалы для аватара/компактного UI. Только для отображения, **не ключ**. | +| `role` | string | да | `'manager'` | Enum → `_DICT.role`. См. 3.1. | +| `salonId` | string\|null | да | `'sal_lenina'` | FK → `salons.id`. У КД — `null` (над салонами). | +| `color` | string | да | `'#7C3AED'` | HEX-цвет сотрудника для шахматки/графиков. | +| `phone` | string | нет | `'+79110000000'` | Контакт. | +| `active` | boolean | да | `true` | `false` — уволен/в отпуске; скрыт из распределения заказов, история сохраняется. | +| `commissionRate` | number\|null | нет | `0.05` | Индивидуальная ставка комиссии (доля, 0.05 = 5%). `null` → берётся из `config.commissionRules` по роли. | + +> **Не хранить здесь:** `visits`, `deals`, `revenue`, `conversion`, `avg`, `rating`, `kpi` — всё это KPI, вычисляется (раздел 5). Хранение = тот самый «захардкоженный KPI» из аудита. + +### 1.3 `clients` — клиенты (сущность, не строка) + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'cli_001'` | Первичный ключ. Префикс `cli_`. Неизменяемый. | +| `name` | string | да | `'Орлова Мария'` | ФИО клиента. | +| `phone` | string | да | `'+79219998877'` | Основной контакт. Уникален в пределах сети (БП-6). | +| `email` | string\|null | нет | `'orlova@mail.ru'` | Необязателен. | +| `source` | string | да | `'instagram'` | Откуда пришёл. Enum → `_DICT.clientSource`. См. 3.4. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. Салон, к которому привязан клиент. | +| `managerId` | string\|null | да | `'usr_001'` | FK → `users.id`. Ответственный менеджер. `null` — не распределён. | +| `status` | string | да | `'lead'` | Enum → `_DICT.clientStatus`. См. 3.5. | +| `createdAt` | string (ISO) | да | `'2026-05-20T09:14:00+03:00'` | Дата появления в CRM. ISO 8601. | +| `note` | string\|null | нет | `'Интересует кухня Hettich'` | Свободный комментарий. | + +> **Не хранить здесь:** число заказов, сумму покупок, LTV — вычисляется из `orders` по `clientId` (раздел 5). + +### 1.4 `orders` — заказы (новая сущность, центральная) + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'ord_001'` | Первичный ключ. Префикс `ord_`. Неизменяемый. | +| `clientId` | string | да | `'cli_001'` | FK → `clients.id`. Обязательна (БП-2). | +| `managerId` | string | да | `'usr_001'` | FK → `users.id`. Менеджер, оформивший заказ. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. Денормализация ради быстрых выборок по салону; должна совпадать с `clients.salonId`. | +| `status` | string | да | `'measuring'` | Enum → `_DICT.orderStatus`. См. 3.2. | +| `amount` | number | да | `60500` | Сумма заказа, ₽. Целое. | +| `prepayment` | number | да | `20000` | Внесённая предоплата, ₽. `0` если нет. | +| `commissionRate` | number\|null | нет | `0.10` | Переопределение ставки комиссии для этого заказа. `null` → используется ставка менеджера (`users.commissionRate`) или дефолт из `config` (БП-5). | +| `createdAt` | string (ISO) | да | `'2026-05-20T10:30:00+03:00'` | Когда оформлен. ISO 8601. | +| `dueDate` | string (ISO date)\|null | да | `'2026-06-15'` | Плановая дата готовности/выдачи. `null` — не назначена. Формат `YYYY-MM-DD`. | +| `closedAt` | string (ISO)\|null | да | `null` | Когда закрыт (`done`/`canceled`). `null` пока открыт. | +| `paymentStatus` | string | да | `'partial'` | Enum → `_DICT.paymentStatus`. См. 3.3. | + +> **Не хранить:** «просрочен ли заказ», «сумма риска» — вычисляется из `dueDate` + `status` + текущей даты (раздел 5, `isOverdue`). + +### 1.5 `appointments` — записи в расписании (шахматка) + +Заменяет `_CHESS_DATA`. Вместо матрицы `менеджер × строка-времени` — плоский список событий с нормальным временем. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'apt_001'` | Первичный ключ. Префикс `apt_`. | +| `managerId` | string | да | `'usr_001'` | FK → `users.id`. Кто проводит. | +| `clientId` | string\|null | да | `'cli_001'` | FK → `clients.id`. `null` — слот занят без клиента (бронь/перерыв). | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. | +| `type` | string | да | `'consult'` | Enum → `_DICT.appointmentType`. См. 3.6. | +| `status` | string | да | `'busy'` | Enum → `_DICT.appointmentStatus`. См. 3.7. | +| `startAt` | string (ISO) | да | `'2026-05-29T14:00:00+03:00'` | Начало. ISO 8601 с таймзоной. | +| `endAt` | string (ISO) | да | `'2026-05-29T16:00:00+03:00'` | Конец. Длительность = `endAt − startAt` (БП-3). | +| `orderId` | string\|null | нет | `'ord_001'` | FK → `orders.id`, если визит привязан к заказу. | + +> Слоты «свободно» в UI **не хранятся** — это пустоты между `appointments` на рабочей сетке (раздел 5, `freeSlots`). + +### 1.6 `requests` — заявки менеджеров администратору / КД + +Заменяет `_MGR_REQUESTS`. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'req_001'` | Первичный ключ. Префикс `req_`. | +| `authorId` | string | да | `'usr_001'` | FK → `users.id`. Кто создал. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. | +| `type` | string | да | `'supply'` | Enum → `_DICT.requestType`. См. 3.8. | +| `priority` | string | да | `'high'` | Enum → `_DICT.priority`. См. 3.9. | +| `title` | string | да | `'Закончились образцы ткани'` | Короткий заголовок. | +| `body` | string | да | `'Нет образцов искусственной замши...'` | Текст заявки. | +| `status` | string | да | `'new'` | Enum → `_DICT.requestStatus`. См. 3.10. | +| `createdAt` | string (ISO) | да | `'2026-05-29T09:14:00+03:00'` | ISO 8601. Заменяет `'сегодня 09:14'`. | +| `assigneeId` | string\|null | нет | `'usr_002'` | FK → `users.id`. Кому эскалировано (`null` — на администратора салона по умолчанию). | + +### 1.7 `config` — конфигурация и комиссии (singleton) + +Заменяет конфиги и комиссии, зашитые в вёрстку. Один объект на всю систему. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `kpiNorm` | number | да | `80` | Норма KPI (порог), %. Ниже — флаг. Было захардкожено в мокапе. | +| `currency` | string | да | `'RUB'` | Валюта системы. | +| `timezone` | string | да | `'Europe/Moscow'` | Базовая таймзона для расчётов дат. | +| `workdayStart` | string | да | `'10:00'` | Начало рабочего дня (для сетки шахматки). | +| `workdayEnd` | string | да | `'20:00'` | Конец рабочего дня. | +| `slotMinutes` | number | да | `60` | Шаг сетки расписания, минут. | +| `commissionRules` | array | да | см. ниже | Дефолтные ставки комиссии по ролям. Ставка по умолчанию — 7% (`0.07`). | + +`commissionRules[]` — элемент: + +| Поле | Тип | Пример | Комментарий | +|---|---|---|---| +| `role` | string | `'manager'` | Enum → `_DICT.role`. | +| `rate` | number | `0.07` | Доля от `amount` закрытого заказа (0.07 = 7%). | + +> Приоритет ставки (от высшего к низшему): `orders.commissionRate` (ставка конкретного заказа) → `users.commissionRate` (индивидуальная ставка менеджера) → `config.commissionRules` по `role` (дефолт, 7% для менеджера). Комиссия начисляется только с заказов в статусе `done` (БП-5). + +### 1.8 `ratings` — оценки этапов заказа и сотрудников + +Хранит оценки, которые участники выставляют друг другу и заказу. Рейтинг сотрудника не хранится — вычисляется (раздел 5). + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'rat_001'` | Первичный ключ. Префикс `rat_`. | +| `orderId` | string | да | `'ord_001'` | FK → `orders.id`. | +| `stage` | string | да | `'assembly'` | Этап заказа, для которого оценка. Enum → `_DICT.ratingStage`. См. 3.13. | +| `authorId` | string | да | `'usr_001'` | FK → `users.id`. Кто оставил оценку. **Скрыт от оцениваемого** (БП-14). | +| `targetId` | string | да | `'usr_003'` | FK → `users.id`. Кого оценивают. | +| `scores` | object | да | `{quality:8, speed:7, cleanliness:9}` | Набор критериев → оценка 1–10 (целое). Ключи зависят от `stage`. См. 3.14. | +| `comment` | string\|null | нет | `'Аккуратная работа'` | Свободный комментарий. | +| `createdAt` | string (ISO) | да | `'2026-06-01T15:00:00+03:00'` | Когда оставлена. ISO 8601. | + +**Кто кого оценивает:** +- Клиент оценивает весь заказ (обязательно при закрытии — БП-13). `stage='order'`. +- Менеджер оценивает замерщика (`stage='measuring'`) и сборщика (`stage='assembly'`). +- Замерщик оценивает менеджера. +- Сборщик оценивает менеджера. +- Администратор и КД видят все оценки, включая `authorId` (кто оценил). +- Оцениваемый (`targetId`) видит только итоговые оценки — **не видит, кто конкретно его оценил** (БП-14). + +**Шкала:** 1–10 (целое). Среднее арифметическое по критериям `scores` = итоговая оценка за этап. Рейтинг сотрудника = среднее по всем его этапам за период (раздел 5). + +--- + +## 2. Связи (явные) + +Все связи — по `id`, не по имени. `N→1` читается «много слева на одного справа». + +``` +salons.adminId → users.id (1→1, директор салона) +users.salonId → salons.id (N→1, сотрудник в салоне; null у КД) +clients.salonId → salons.id (N→1) +clients.managerId → users.id (N→1, ответственный менеджер; null = не распределён) +orders.clientId → clients.id (N→1, у заказа всегда есть клиент) +orders.managerId → users.id (N→1) +orders.salonId → salons.id (N→1, денормализация = clients.salonId) +appointments.managerId → users.id (N→1) +appointments.clientId → clients.id (N→1; null допустим) +appointments.salonId → salons.id (N→1) +appointments.orderId → orders.id (N→1; nullable) +requests.authorId → users.id (N→1) +requests.salonId → salons.id (N→1) +requests.assigneeId → users.id (N→1; nullable) +shiftRequests.authorId → users.id (N→1) +shiftRequests.withUserId→ users.id (N→1; null для отгула) +ratings.orderId → orders.id (N→1) +ratings.authorId → users.id (N→1, кто оценил; скрыт от targetId) +ratings.targetId → users.id (N→1, кого оценивают) +commissionRules.role → _DICT.role (значение из справочника) +``` + +Диаграмма верхнего уровня: + +``` + ┌──────────┐ + │ salons │◄──────────────┐ + └────┬─────┘ │ salonId (денормализация) + adminId │ salonId │ + ▼ │ + ┌──────────┐ managerId │ + ┌───────►│ users │◄──────────────┤ + │ └────┬─────┘ │ + │ authorId │ managerId │ + │ ▼ │ + │ ┌──────────┐ clientId ┌──┴──────┐ + │ │ clients │◄───────────┤ orders │ + │ └──────────┘ └─────────┘ + │ ▲ ▲ + │ │ clientId │ orderId + │ ┌────┴──────────┐ │ + │ │ appointments ├────────────┘ + │ └───────────────┘ + │ +┌──┴──────────┐ ┌───────────────┐ ┌──────────┐ +│ requests │ │ shiftRequests │ │ ratings │ +└─────────────┘ └───────────────┘ └──────────┘ +``` + +--- + +## 3. Справочники (`_DICT`) — все enum зафиксированы + +Хранятся в `data.js` как `var _DICT = { ... }`. **В полях сущностей допустимы только ключи из этих справочников.** Свободные строки запрещены (правка аудита). UI берёт `label`/`color` отсюда, а не хардкодит. + +### 3.1 `role` — роль сотрудника +| Ключ | Русское название | Описание | +|---|---|---| +| `owner` | КД (собственник) | Видит всю сеть. `salonId = null`. | +| `admin` | Администратор | Директор одного салона. | +| `manager` | Менеджер | Продавец-консультант. | +| `measurer` | Замерщик | Проводит замеры, привязан к салону. | +| `assembler` | Сборщик | Выполняет монтаж, привязан к салону. | + +> Роли `measurer` и `assembler` активны в v1 — реализованы в мокапах `mockup_measurer.html` и `mockup_assembler.html`. + +### 3.2 `orderStatus` — статус заказа +| Ключ | Русское название | Описание | +|---|---|---| +| `lead` | Лид | Новый контакт, не конвертирован в заказ. | +| `measuring` | Замер | Назначен/проводится замер. | +| `project` | Проект | Разрабатывается дизайн-проект. | +| `tech` | Техника | Подбор и согласование техники. | +| `technolog` | Технолог | Проверка технологом. | +| `production` | Производство | Передан на фабрику. | +| `assembly` | Сборка | Монтаж у клиента. | +| `done` | Закрыт | Выдан, оплачен. Начисляется комиссия. | +| `canceled` | Отменён | Сделка не состоялась. | + +### 3.3 `paymentStatus` — статус оплаты +| Ключ | Русское название | Описание | +|---|---|---| +| `none` | Без оплаты | Предоплаты нет. | +| `partial` | Частичная | Внесена предоплата, остаток не закрыт. | +| `paid` | Оплачен | Оплачено полностью. | +| `refunded` | Возврат | Средства возвращены клиенту. | + +### 3.4 `clientSource` — источник клиента +| Ключ | Русское название | Описание | +|---|---|---| +| `walkin` | Зашёл в салон | Офлайн-трафик. | +| `instagram` | Instagram | Соцсеть. | +| `avito` | Avito | Доска объявлений. | +| `referral` | Рекомендация | По совету клиента. | +| `site` | Сайт | Заявка с сайта. | +| `other` | Другое | Прочее. | + +### 3.5 `clientStatus` — статус клиента в воронке +| Ключ | Русское название | Описание | +|---|---|---| +| `lead` | Лид | Новый контакт, без заказа. | +| `active` | Активный | Есть открытый заказ. | +| `repeat` | Повторный | Был ≥1 закрытый заказ. | +| `lost` | Потерян | Отказ/неактивен. | + +### 3.6 `appointmentType` — тип записи в расписании +| Ключ | Русское название | Описание | +|---|---|---| +| `consult` | Консультация | Первичная встреча/подбор. | +| `follow` | Повторный контакт | Дозвон/повторная встреча. | +| `measure` | Замер | Выезд/замер. | +| `tech` | Техническая | Согласование проекта, документы. | + +### 3.7 `appointmentStatus` — статус записи +| Ключ | Русское название | Описание | +|---|---|---| +| `busy` | Занято | Слот забронирован, событие предстоит. | +| `done` | Завершено | Встреча прошла. | +| `noshow` | Не пришёл | Клиент не явился. | +| `canceled` | Отменено | Запись отменена. | + +> Статуса `free` нет: свободное время — это отсутствие записи, а не запись со статусом (см. раздел 5, `freeSlots`). + +### 3.8 `requestType` — тип заявки +| Ключ | Русское название | Описание | +|---|---|---| +| `supply` | Снабжение | Образцы, каталоги, материалы. | +| `escalate` | Эскалация | Конфликт/проблема, нужно вмешательство. | +| `visit` | Выезд/транспорт | Нужен ресурс для выезда к клиенту. | +| `schedule` | Расписание | Вопрос по сменам/графику. | +| `other` | Другое | Прочее. | + +### 3.9 `priority` — приоритет +| Ключ | Русское название | Описание | +|---|---|---| +| `low` | Низкий | Можно отложить. | +| `normal` | Обычный | Штатный. | +| `high` | Высокий | Срочно, влияет на деньги/клиента. | + +### 3.10 `requestStatus` — статус заявки +| Ключ | Русское название | Описание | +|---|---|---| +| `new` | Новая | Не обработана. | +| `inWork` | В работе | Взята в обработку. | +| `done` | Выполнена | Закрыта. | +| `rejected` | Отклонена | Отказано. | + +### 3.11 `shiftRequestType` — тип запроса по смене +| Ключ | Русское название | Описание | +|---|---|---| +| `swap` | Обмен сменами | С другим сотрудником (`withUserId` обязателен). | +| `off` | Отгул | Без замены (`withUserId = null`). | +| `extra` | Доп. смена | Просьба добавить смену. | + +### 3.12 `shiftRequestStatus` — статус запроса по смене +| Ключ | Русское название | Описание | +|---|---|---| +| `pending` | На рассмотрении | Ждёт решения администратора. | +| `approved` | Одобрено | Согласовано. | +| `declined` | Отклонено | Отказано. | + +### 3.13 `ratingStage` — этап заказа, для которого выставлена оценка +| Ключ | Название | +|---|---| +| `measuring` | Замер | +| `project` | Проект | +| `assembly` | Сборка | +| `order` | Весь заказ (клиентская оценка) | + +### 3.14 `ratingCriteria` — критерии оценки (ключи `scores`) +| Ключ | Название | Этапы | +|---|---|---| +| `quality` | Качество работы | measuring, project, assembly | +| `speed` | Скорость | measuring, assembly | +| `cleanliness` | Чистота | assembly | +| `deadlines` | Сроки | project, assembly | +| `communication` | Взаимодействие | measuring | +| `overall` | Общая оценка | order | +| `service` | Сервис | order | +| `result` | Результат | order | + +> Набор критериев в `ratings.scores` зависит от `stage`: Замер → `quality`, `speed`, `communication`; Проект → `quality`, `deadlines`; Сборка → `quality`, `speed`, `cleanliness`, `deadlines`; Весь заказ → `overall`, `service`, `result`. + +--- + +## 4. Сущность `shiftRequests` — запросы по сменам + +Заменяет `_SHIFT_REQS`. Вынесена отдельно от справочников для полноты. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'shr_001'` | Префикс `shr_`. | +| `authorId` | string | да | `'usr_002'` | FK → `users.id`. Кто просит. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. | +| `type` | string | да | `'swap'` | Enum → `_DICT.shiftRequestType`. См. 3.11. | +| `withUserId` | string\|null | да | `'usr_003'` | FK → `users.id`. С кем обмен; `null` для `off`. | +| `dateFrom` | string (ISO date) | да | `'2026-05-31'` | Дата запроса. `YYYY-MM-DD`. Заменяет `'31 мая ↔ 1 июня'`. | +| `dateTo` | string (ISO date)\|null | нет | `'2026-06-01'` | Вторая дата для обмена; `null` если одна. | +| `status` | string | да | `'pending'` | Enum → `_DICT.shiftRequestStatus`. См. 3.12. | + +--- + +## 5. Вычисляемые поля (НЕ хранятся) + +Считаются на лету из хранимых данных. **Запрещено сохранять как поля сущностей** — это и есть «захардкоженный KPI» из аудита. Реализуются хелперами в `data.js`. + +### По менеджеру (`users` с `role='manager'`), за период +| Поле | Формула (словами) | Источник | +|---|---|---| +| `visits` | Кол-во `appointments` менеджера со `status='done'` за период | `appointments` | +| `deals` | Кол-во `orders` менеджера со `status='done'` за период | `orders` | +| `revenue` | Сумма `amount` закрытых заказов менеджера за период | `orders` | +| `conversion` | `deals / visits × 100`, % (0 если `visits=0`) | производное | +| `avg` | `revenue / deals`, средний чек (0 если `deals=0`) | производное | +| `commission` | По БП-5: ставка × сумма закрытых заказов | `orders` + `users` + `config` | +| `kpi` | Сводный балл; базово = `conversion`, сравнивается с `config.kpiNorm` | производное | +| `belowNorm` | `kpi < config.kpiNorm` → флаг | производное | + +### По сотруднику — рейтинг из оценок +| Поле | Формула (словами) | Источник | +|---|---|---| +| `employeeRating(userId, period)` | Среднее всех значений `scores` по всем `ratings` где `targetId=userId` за период | `ratings` | +| `orderRating(orderId)` | Клиентская оценка заказа: из `ratings` с `orderId`, `stage='order'` и `authorId = clientId` заказа | `ratings` | + +> Рейтинг сотрудника = среднее всех `scores` по всем его этапам за период (БП-15). Не хранится. + +### По салону (`salons`) +| Поле | Формула | Источник | +|---|---|---| +| `orders` | Кол-во заказов салона за месяц | `orders` по `salonId` | +| `revenue` | Сумма `amount` закрытых заказов салона за месяц | `orders` | +| `overdue` | Кол-во заказов с `isOverdue=true` | `orders` | +| `overdueRisk` | Сумма `amount` просроченных заказов | `orders` | +| `newLeads` | Кол-во `clients` со `status='lead'` за период | `clients` | +| `planFulfillment` | `revenue / revenuePlan × 100`, % | производное | +| `salonStatus` | `'ok'`/`'warn'`/`'risk'` по `planFulfillment` и `overdue` | производное | + +### По заказу (`orders`) +| Поле | Формула | Источник | +|---|---|---| +| `isOverdue` | `dueDate < сегодня` И `status ∉ {done, canceled}` | `orders` + дата | +| `balance` | `amount − prepayment`, остаток к оплате | производное | + +### По клиенту (`clients`) +| Поле | Формула | Источник | +|---|---|---| +| `ordersCount` | Кол-во заказов клиента | `orders` по `clientId` | +| `ltv` | Сумма `amount` закрытых заказов клиента | `orders` | + +### По расписанию +| Поле | Формула | Источник | +|---|---|---| +| `freeSlots` | Слоты рабочей сетки (`config.workdayStart..End` шагом `slotMinutes`) без `appointments` | производное | + +--- + +## 6. Бизнес-правила (утверждения, не код) + +- **БП-1. Единый формат времени.** Все моменты времени хранятся как ISO 8601 с таймзоной (`'2026-05-29T14:00:00+03:00'`); только-дата — как `YYYY-MM-DD`. Человекочитаемые строки (`'сегодня 09:14'`, `'31 мая'`) и сетки-слоты как ключи запрещены. Форматирование для UI — на стороне отображения. +- **БП-2. Заказ без клиента не существует.** `orders.clientId` обязателен и должен указывать на существующего `clients.id`. Создать заказ можно только после создания клиента. +- **БП-3. Длительность записи = разница времён.** `appointments.endAt > startAt`. Длительность не хранится отдельным полем. +- **БП-4. Согласованность салона.** `orders.salonId` должен совпадать с `clients.salonId` ответственного клиента. При смене салона клиента — пересобрать денормализацию. +- **БП-5. Комиссия только с закрытых заказов.** Начисляется лишь для `orderStatus='done'`. Ставка определяется в порядке приоритета: `orders.commissionRate` → `users.commissionRate` → `config.commissionRules[role].rate` (дефолт 7%). База — `orders.amount`. Результат — `getCommission(userId, period)` в разделе 5. +- **БП-6. Телефон клиента уникален в сети.** Дубликат телефона = тот же клиент. Повторное обращение не создаёт нового `clients`, а привязывается к существующему. +- **БП-7. KPI не хранится.** Любой показатель эффективности (раздел 5) вычисляется. Поле KPI в хранимой сущности — ошибка ревью. +- **БП-8. Enum только из `_DICT`.** Запись значения вне справочника недопустима. Новое значение → сначала в `_DICT`, потом в данные. +- **БП-9. id неизменяем и осмыслен по префиксу.** После создания `id` не меняется (на него ссылаются). Префиксы: `sal_`, `usr_`, `cli_`, `ord_`, `apt_`, `req_`, `shr_`, `rat_`. +- **БП-10. Удаление — мягкое.** Сотрудники/салоны/клиенты не удаляются физически (на них висит история заказов) — ставится `active=false` / `status='lost'`. Жёсткое удаление запрещено. +- **БП-11. Эскалация = смена адресата.** `requests` по умолчанию идёт администратору салона. КД видит её, только если `assigneeId` указывает на пользователя с `role='owner'` (эскалация) или `type='escalate'`. +- **БП-12. КД вне салона.** У `role='owner'` `salonId = null`. Любая выборка «по салону» для КД означает «по всем салонам». +- **БП-13. Клиентская оценка обязательна при закрытии.** Перевод заказа в статус `done` невозможен без клиентской оценки (`ratings` с `stage='order'`). Без неё закрыть заказ нельзя. +- **БП-14. Анонимность оценки для оцениваемого.** Оцениваемый (`targetId`) не видит `authorId`. Только пользователи с ролью `admin` и `owner` видят, кто оценил. +- **БП-15. Рейтинг сотрудника вычисляется.** Рейтинг = среднее всех `scores` по всем `ratings` где `targetId=userId` за период. Не хранится — вычисляется (раздел 5, `employeeRating`). + +--- + +## 7. Доступ по ролям + +Принцип: **менеджер видит своё, администратор — свой салон, КД — всю сеть. Замерщик и сборщик — только свои назначенные этапы.** + +| Сущность / данные | Менеджер (`manager`) | Замерщик (`measurer`) | Сборщик (`assembler`) | Администратор (`admin`) | КД (`owner`) | +|---|---|---|---|---|---| +| Свои клиенты | ✅ чтение/запись | ❌ | ❌ | ✅ все клиенты салона | ✅ все клиенты сети | +| Чужие клиенты | ❌ | ❌ | ❌ | ✅ в своём салоне | ✅ | +| Свои заказы | ✅ чтение/запись | ✅ только назначенные (этап замера) | ✅ только назначенные (этап сборки) | ✅ все заказы салона | ✅ все заказы сети | +| Чужие заказы | ❌ | ❌ | ❌ | ✅ свой салон | ✅ | +| Финансы заказа (`amount`, комиссия) | ✅ свои | ❌ | ❌ | ✅ свой салон | ✅ | +| Своё расписание (`appointments`) | ✅ чтение/запись | ✅ своё | ✅ своё | ✅ весь салон (шахматка) | ✅ все салоны | +| Свои KPI | ✅ только просмотр | ✅ только просмотр | ✅ только просмотр | ✅ KPI всех в салоне | ✅ KPI всей сети | +| Чужие KPI | ❌ | ❌ | ❌ | ✅ свой салон | ✅ | +| Оставлять оценки (`ratings`) | ✅ замерщика и сборщика | ✅ менеджера | ✅ менеджера | ✅ просмотр всех (видит `authorId`) | ✅ просмотр всех (видит `authorId`) | +| Получать оценки | ✅ от замерщика/сборщика | ✅ от менеджера и клиента | ✅ от менеджера и клиента | — | — | +| Видеть `authorId` оценки | ❌ (только итог) | ❌ (только итог) | ❌ (только итог) | ✅ | ✅ | +| Создание `requests` | ✅ создаёт | ✅ создаёт | ✅ создаёт | ✅ обрабатывает свой салон | ✅ видит эскалации | +| `shiftRequests` | ✅ создаёт свои | ✅ создаёт свои | ✅ создаёт свои | ✅ одобряет/отклоняет в салоне | ✅ просмотр | +| `config` / `commissionRules` | ❌ | ❌ | ❌ | ❌ просмотр своих ставок | ✅ полное управление | +| Управление `users` | ❌ | ❌ | ❌ | ✅ сотрудники своего салона | ✅ все, включая админов | +| Управление `salons` | ❌ | ❌ | ❌ | ❌ свой салон (чтение) | ✅ полное | +| Сводка по сети (все салоны) | ❌ | ❌ | ❌ | ❌ только свой салон | ✅ | + +> Замерщик и сборщик видят только свои назначенные заказы (конкретный этап) и своё расписание; финансовые данные заказа (сумма, комиссия) им недоступны. Оценки оставляют и получают по правилам раздела 1.8 и БП-13–15. + +> Разграничение в первом прототипе — на уровне фильтрации в UI по `currentUser.role` и `currentUser.salonId`. Серверной проверки нет (бэкенда нет) — это известное ограничение прототипа, не передавать в прод без авторизации на сервере. + +--- + +## 8. Скелет `data.js` (порядок и форма) + +```js +var _DICT = { role:{...}, orderStatus:{...}, ratingStage:{...}, ratingCriteria:{...}, /* ... разделы 3.1–3.14 */ }; + +var config = { + kpiNorm:80, currency:'RUB', timezone:'Europe/Moscow', + workdayStart:'10:00', workdayEnd:'20:00', slotMinutes:60, + commissionRules:[ {role:'manager', rate:0.07} ] // дефолт 7% +}; + +var salons = [ /* раздел 1.1 */ ]; +var users = [ /* раздел 1.2 */ ]; +var clients = [ /* раздел 1.3 */ ]; +var orders = [ /* раздел 1.4 — включая commissionRate */ ]; +var appointments = [ /* раздел 1.5 */ ]; +var requests = [ /* раздел 1.6 */ ]; +var shiftRequests = [ /* раздел 4 */ ]; +var ratings = [ /* раздел 1.8 */ ]; + +// Вычисляемые (раздел 5) — функции, НЕ поля: +function getMgrStats(userId, period){ /* visits/deals/revenue/conversion/avg/kpi */ } +function getSalonStats(salonId, period){ /* orders/revenue/overdue/... */ } +function isOverdue(order){ /* dueDate + status + сегодня */ } +function getFreeSlots(userId, date){ /* сетка минус appointments */ } +function getCommission(userId, period){ /* БП-5: orders.commissionRate → users.commissionRate → config */ } +function employeeRating(userId, period){ /* среднее scores по ratings.targetId=userId */ } +function orderRating(orderId){ /* клиентская оценка: ratings stage='order', authorId=clientId */ } +``` + +--- + +**Конец контракта.** При расхождении кода и этого документа — правится код. При расхождении документа и реальности — заводится правка документа, не молчаливый обход. diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md new file mode 100644 index 0000000..cf9890e --- /dev/null +++ b/docs/SCHEMA.md @@ -0,0 +1,528 @@ +# SCHEMA.md — Контракт данных CRM мебельного салона + +**Проект:** CRM для сети мебельных салонов (РФ) +**Версия:** 1.1 · 2026-05-29 +**Реализация:** JavaScript, in-memory в `data.js` (в перспективе — `localStorage`). Бэкенда в первом прототипе нет. +**Соглашения:** поля — `camelCase` (английские); id — строки вида `'ord_001'`, `'cli_001'`, `'usr_001'`. + +Этот документ — **единственный источник истины** по структуре данных. Если экран показывает поле, которого здесь нет, — прав документ, а не экран. Если разработчику что-то непонятно — это баг документа, заведите вопрос, не додумывайте. + +--- + +## 0. Что этот контракт чинит (из аудита) + +| Проблема аудита | Решение в схеме | +|---|---| +| Заказы и клиенты не существуют в общем источнике | Сущности `orders`, `clients` в `data.js` | +| Клиент — строка, не сущность | `clients[]` с `id`, заказы ссылаются `clientId` | +| KPI захардкожены | KPI вынесены в раздел 5 «Вычисляемые поля», не хранятся | +| Связи по именам | Все связи через `id` (раздел 2) | +| 3 формата времени | Единый стандарт: ISO 8601 (раздел 6, БП-1) | +| Enum как свободные строки | Все enum зафиксированы в `_DICT` (раздел 3) | +| Комиссии и конфиги в вёрстке | Сущность `config` + `commissionRules` (раздел 1.7) | +| id менеджера по инициалам (`'ak'`) | `id` = `'usr_001'` (стабильный), инициалы → поле `initials` | +| Оценки сотрудников нигде не хранятся / непрозрачны | Сущность `ratings` (раздел 1.8), рейтинг вычисляется (раздел 5), анонимность для оцениваемого (БП-14) | +| Ставка комиссии захардкожена одним числом | Трёхуровневый приоритет: `orders.commissionRate` → `users.commissionRate` → `config.commissionRules` (БП-5) | + +--- + +## 1. Сущности (хранимые) + +Порядок объявления в `data.js`: `config` → `salons` → `users` → `clients` → `orders` → `appointments` → `requests` → `shiftRequests` → `ratings`. Сущность не может ссылаться на ещё не объявленную — отсюда порядок. + +### 1.1 `salons` — салоны сети + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'sal_lenina'` | Первичный ключ. Префикс `sal_`. Неизменяемый. | +| `name` | string | да | `'Салон на Ленина'` | Отображаемое название. | +| `address` | string | да | `'пр. Ленина, 42'` | Физический адрес. | +| `color` | string | да | `'#3B82F6'` | HEX-цвет для UI (графики, бейджи салона). | +| `adminId` | string\|null | да | `'usr_002'` | FK → `users.id`. Директор салона. `null` если вакансия. | +| `revenuePlan` | number | да | `1700000` | План выручки на текущий месяц, ₽. Целое. | +| `ordersPlan` | number | да | `30` | План по кол-ву заказов на месяц. Целое. | +| `active` | boolean | да | `true` | `false` — салон закрыт/заморожен, скрыт из активных списков. | + +> **Не хранить здесь:** `orders`, `revenue`, `overdue`, `newLeads` — это факт, вычисляется из `orders` (раздел 5). + +### 1.2 `users` — сотрудники (все роли) + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'usr_001'` | Первичный ключ. Префикс `usr_` + порядковый номер. **Никогда не основан на инициалах** (коллизии). Неизменяемый. | +| `name` | string | да | `'Анна Кузнецова'` | Полное имя. | +| `initials` | string | да | `'АК'` | Инициалы для аватара/компактного UI. Только для отображения, **не ключ**. | +| `role` | string | да | `'manager'` | Enum → `_DICT.role`. См. 3.1. | +| `salonId` | string\|null | да | `'sal_lenina'` | FK → `salons.id`. У КД — `null` (над салонами). | +| `color` | string | да | `'#7C3AED'` | HEX-цвет сотрудника для шахматки/графиков. | +| `phone` | string | нет | `'+79110000000'` | Контакт. | +| `active` | boolean | да | `true` | `false` — уволен/в отпуске; скрыт из распределения заказов, история сохраняется. | +| `commissionRate` | number\|null | нет | `0.05` | Индивидуальная ставка комиссии (доля, 0.05 = 5%). `null` → берётся из `config.commissionRules` по роли. | + +> **Не хранить здесь:** `visits`, `deals`, `revenue`, `conversion`, `avg`, `rating`, `kpi` — всё это KPI, вычисляется (раздел 5). Хранение = тот самый «захардкоженный KPI» из аудита. + +### 1.3 `clients` — клиенты (сущность, не строка) + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'cli_001'` | Первичный ключ. Префикс `cli_`. Неизменяемый. | +| `name` | string | да | `'Орлова Мария'` | ФИО клиента. | +| `phone` | string | да | `'+79219998877'` | Основной контакт. Уникален в пределах сети (БП-6). | +| `email` | string\|null | нет | `'orlova@mail.ru'` | Необязателен. | +| `source` | string | да | `'instagram'` | Откуда пришёл. Enum → `_DICT.clientSource`. См. 3.4. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. Салон, к которому привязан клиент. | +| `managerId` | string\|null | да | `'usr_001'` | FK → `users.id`. Ответственный менеджер. `null` — не распределён. | +| `status` | string | да | `'lead'` | Enum → `_DICT.clientStatus`. См. 3.5. | +| `createdAt` | string (ISO) | да | `'2026-05-20T09:14:00+03:00'` | Дата появления в CRM. ISO 8601. | +| `note` | string\|null | нет | `'Интересует кухня Hettich'` | Свободный комментарий. | + +> **Не хранить здесь:** число заказов, сумму покупок, LTV — вычисляется из `orders` по `clientId` (раздел 5). + +### 1.4 `orders` — заказы (новая сущность, центральная) + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'ord_001'` | Первичный ключ. Префикс `ord_`. Неизменяемый. | +| `clientId` | string | да | `'cli_001'` | FK → `clients.id`. Обязательна (БП-2). | +| `managerId` | string | да | `'usr_001'` | FK → `users.id`. Менеджер, оформивший заказ. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. Денормализация ради быстрых выборок по салону; должна совпадать с `clients.salonId`. | +| `status` | string | да | `'measuring'` | Enum → `_DICT.orderStatus`. См. 3.2. | +| `amount` | number | да | `60500` | Сумма заказа, ₽. Целое. | +| `prepayment` | number | да | `20000` | Внесённая предоплата, ₽. `0` если нет. | +| `commissionRate` | number\|null | нет | `0.10` | Переопределение ставки комиссии для этого заказа. `null` → используется ставка менеджера (`users.commissionRate`) или дефолт из `config` (БП-5). | +| `createdAt` | string (ISO) | да | `'2026-05-20T10:30:00+03:00'` | Когда оформлен. ISO 8601. | +| `dueDate` | string (ISO date)\|null | да | `'2026-06-15'` | Плановая дата готовности/выдачи. `null` — не назначена. Формат `YYYY-MM-DD`. | +| `closedAt` | string (ISO)\|null | да | `null` | Когда закрыт (`done`/`canceled`). `null` пока открыт. | +| `paymentStatus` | string | да | `'partial'` | Enum → `_DICT.paymentStatus`. См. 3.3. | + +> **Не хранить:** «просрочен ли заказ», «сумма риска» — вычисляется из `dueDate` + `status` + текущей даты (раздел 5, `isOverdue`). + +### 1.5 `appointments` — записи в расписании (шахматка) + +Заменяет `_CHESS_DATA`. Вместо матрицы `менеджер × строка-времени` — плоский список событий с нормальным временем. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'apt_001'` | Первичный ключ. Префикс `apt_`. | +| `managerId` | string | да | `'usr_001'` | FK → `users.id`. Кто проводит. | +| `clientId` | string\|null | да | `'cli_001'` | FK → `clients.id`. `null` — слот занят без клиента (бронь/перерыв). | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. | +| `type` | string | да | `'consult'` | Enum → `_DICT.appointmentType`. См. 3.6. | +| `status` | string | да | `'busy'` | Enum → `_DICT.appointmentStatus`. См. 3.7. | +| `startAt` | string (ISO) | да | `'2026-05-29T14:00:00+03:00'` | Начало. ISO 8601 с таймзоной. | +| `endAt` | string (ISO) | да | `'2026-05-29T16:00:00+03:00'` | Конец. Длительность = `endAt − startAt` (БП-3). | +| `orderId` | string\|null | нет | `'ord_001'` | FK → `orders.id`, если визит привязан к заказу. | + +> Слоты «свободно» в UI **не хранятся** — это пустоты между `appointments` на рабочей сетке (раздел 5, `freeSlots`). + +### 1.6 `requests` — заявки менеджеров администратору / КД + +Заменяет `_MGR_REQUESTS`. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'req_001'` | Первичный ключ. Префикс `req_`. | +| `authorId` | string | да | `'usr_001'` | FK → `users.id`. Кто создал. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. | +| `type` | string | да | `'supply'` | Enum → `_DICT.requestType`. См. 3.8. | +| `priority` | string | да | `'high'` | Enum → `_DICT.priority`. См. 3.9. | +| `title` | string | да | `'Закончились образцы ткани'` | Короткий заголовок. | +| `body` | string | да | `'Нет образцов искусственной замши...'` | Текст заявки. | +| `status` | string | да | `'new'` | Enum → `_DICT.requestStatus`. См. 3.10. | +| `createdAt` | string (ISO) | да | `'2026-05-29T09:14:00+03:00'` | ISO 8601. Заменяет `'сегодня 09:14'`. | +| `assigneeId` | string\|null | нет | `'usr_002'` | FK → `users.id`. Кому эскалировано (`null` — на администратора салона по умолчанию). | + +### 1.7 `config` — конфигурация и комиссии (singleton) + +Заменяет конфиги и комиссии, зашитые в вёрстку. Один объект на всю систему. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `kpiNorm` | number | да | `80` | Норма KPI (порог), %. Ниже — флаг. Было захардкожено в мокапе. | +| `currency` | string | да | `'RUB'` | Валюта системы. | +| `timezone` | string | да | `'Europe/Moscow'` | Базовая таймзона для расчётов дат. | +| `workdayStart` | string | да | `'10:00'` | Начало рабочего дня (для сетки шахматки). | +| `workdayEnd` | string | да | `'20:00'` | Конец рабочего дня. | +| `slotMinutes` | number | да | `60` | Шаг сетки расписания, минут. | +| `commissionRules` | array | да | см. ниже | Дефолтные ставки комиссии по ролям. Ставка по умолчанию — 7% (`0.07`). | + +`commissionRules[]` — элемент: + +| Поле | Тип | Пример | Комментарий | +|---|---|---|---| +| `role` | string | `'manager'` | Enum → `_DICT.role`. | +| `rate` | number | `0.07` | Доля от `amount` закрытого заказа (0.07 = 7%). | + +> Приоритет ставки (от высшего к низшему): `orders.commissionRate` (ставка конкретного заказа) → `users.commissionRate` (индивидуальная ставка менеджера) → `config.commissionRules` по `role` (дефолт, 7% для менеджера). Комиссия начисляется только с заказов в статусе `done` (БП-5). + +### 1.8 `ratings` — оценки этапов заказа и сотрудников + +Хранит оценки, которые участники выставляют друг другу и заказу. Рейтинг сотрудника не хранится — вычисляется (раздел 5). + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'rat_001'` | Первичный ключ. Префикс `rat_`. | +| `orderId` | string | да | `'ord_001'` | FK → `orders.id`. | +| `stage` | string | да | `'assembly'` | Этап заказа, для которого оценка. Enum → `_DICT.ratingStage`. См. 3.13. | +| `authorId` | string | да | `'usr_001'` | FK → `users.id`. Кто оставил оценку. **Скрыт от оцениваемого** (БП-14). | +| `targetId` | string | да | `'usr_003'` | FK → `users.id`. Кого оценивают. | +| `scores` | object | да | `{quality:8, speed:7, cleanliness:9}` | Набор критериев → оценка 1–10 (целое). Ключи зависят от `stage`. См. 3.14. | +| `comment` | string\|null | нет | `'Аккуратная работа'` | Свободный комментарий. | +| `createdAt` | string (ISO) | да | `'2026-06-01T15:00:00+03:00'` | Когда оставлена. ISO 8601. | + +**Кто кого оценивает:** +- Клиент оценивает весь заказ (обязательно при закрытии — БП-13). `stage='order'`. +- Менеджер оценивает замерщика (`stage='measuring'`) и сборщика (`stage='assembly'`). +- Замерщик оценивает менеджера. +- Сборщик оценивает менеджера. +- Администратор и КД видят все оценки, включая `authorId` (кто оценил). +- Оцениваемый (`targetId`) видит только итоговые оценки — **не видит, кто конкретно его оценил** (БП-14). + +**Шкала:** 1–10 (целое). Среднее арифметическое по критериям `scores` = итоговая оценка за этап. Рейтинг сотрудника = среднее по всем его этапам за период (раздел 5). + +--- + +## 2. Связи (явные) + +Все связи — по `id`, не по имени. `N→1` читается «много слева на одного справа». + +``` +salons.adminId → users.id (1→1, директор салона) +users.salonId → salons.id (N→1, сотрудник в салоне; null у КД) +clients.salonId → salons.id (N→1) +clients.managerId → users.id (N→1, ответственный менеджер; null = не распределён) +orders.clientId → clients.id (N→1, у заказа всегда есть клиент) +orders.managerId → users.id (N→1) +orders.salonId → salons.id (N→1, денормализация = clients.salonId) +appointments.managerId → users.id (N→1) +appointments.clientId → clients.id (N→1; null допустим) +appointments.salonId → salons.id (N→1) +appointments.orderId → orders.id (N→1; nullable) +requests.authorId → users.id (N→1) +requests.salonId → salons.id (N→1) +requests.assigneeId → users.id (N→1; nullable) +shiftRequests.authorId → users.id (N→1) +shiftRequests.withUserId→ users.id (N→1; null для отгула) +ratings.orderId → orders.id (N→1) +ratings.authorId → users.id (N→1, кто оценил; скрыт от targetId) +ratings.targetId → users.id (N→1, кого оценивают) +commissionRules.role → _DICT.role (значение из справочника) +``` + +Диаграмма верхнего уровня: + +``` + ┌──────────┐ + │ salons │◄──────────────┐ + └────┬─────┘ │ salonId (денормализация) + adminId │ salonId │ + ▼ │ + ┌──────────┐ managerId │ + ┌───────►│ users │◄──────────────┤ + │ └────┬─────┘ │ + │ authorId │ managerId │ + │ ▼ │ + │ ┌──────────┐ clientId ┌──┴──────┐ + │ │ clients │◄───────────┤ orders │ + │ └──────────┘ └─────────┘ + │ ▲ ▲ + │ │ clientId │ orderId + │ ┌────┴──────────┐ │ + │ │ appointments ├────────────┘ + │ └───────────────┘ + │ +┌──┴──────────┐ ┌───────────────┐ ┌──────────┐ +│ requests │ │ shiftRequests │ │ ratings │ +└─────────────┘ └───────────────┘ └──────────┘ +``` + +--- + +## 3. Справочники (`_DICT`) — все enum зафиксированы + +Хранятся в `data.js` как `var _DICT = { ... }`. **В полях сущностей допустимы только ключи из этих справочников.** Свободные строки запрещены (правка аудита). UI берёт `label`/`color` отсюда, а не хардкодит. + +### 3.1 `role` — роль сотрудника +| Ключ | Русское название | Описание | +|---|---|---| +| `owner` | КД (собственник) | Видит всю сеть. `salonId = null`. | +| `admin` | Администратор | Директор одного салона. | +| `manager` | Менеджер | Продавец-консультант. | +| `measurer` | Замерщик | Проводит замеры, привязан к салону. | +| `assembler` | Сборщик | Выполняет монтаж, привязан к салону. | + +> Роли `measurer` и `assembler` активны в v1 — реализованы в мокапах `mockup_measurer.html` и `mockup_assembler.html`. + +### 3.2 `orderStatus` — статус заказа +| Ключ | Русское название | Описание | +|---|---|---| +| `lead` | Лид | Новый контакт, не конвертирован в заказ. | +| `measuring` | Замер | Назначен/проводится замер. | +| `project` | Проект | Разрабатывается дизайн-проект. | +| `tech` | Техника | Подбор и согласование техники. | +| `technolog` | Технолог | Проверка технологом. | +| `production` | Производство | Передан на фабрику. | +| `assembly` | Сборка | Монтаж у клиента. | +| `done` | Закрыт | Выдан, оплачен. Начисляется комиссия. | +| `canceled` | Отменён | Сделка не состоялась. | + +### 3.3 `paymentStatus` — статус оплаты +| Ключ | Русское название | Описание | +|---|---|---| +| `none` | Без оплаты | Предоплаты нет. | +| `partial` | Частичная | Внесена предоплата, остаток не закрыт. | +| `paid` | Оплачен | Оплачено полностью. | +| `refunded` | Возврат | Средства возвращены клиенту. | + +### 3.4 `clientSource` — источник клиента +| Ключ | Русское название | Описание | +|---|---|---| +| `walkin` | Зашёл в салон | Офлайн-трафик. | +| `instagram` | Instagram | Соцсеть. | +| `avito` | Avito | Доска объявлений. | +| `referral` | Рекомендация | По совету клиента. | +| `site` | Сайт | Заявка с сайта. | +| `other` | Другое | Прочее. | + +### 3.5 `clientStatus` — статус клиента в воронке +| Ключ | Русское название | Описание | +|---|---|---| +| `lead` | Лид | Новый контакт, без заказа. | +| `active` | Активный | Есть открытый заказ. | +| `repeat` | Повторный | Был ≥1 закрытый заказ. | +| `lost` | Потерян | Отказ/неактивен. | + +### 3.6 `appointmentType` — тип записи в расписании +| Ключ | Русское название | Описание | +|---|---|---| +| `consult` | Консультация | Первичная встреча/подбор. | +| `follow` | Повторный контакт | Дозвон/повторная встреча. | +| `measure` | Замер | Выезд/замер. | +| `tech` | Техническая | Согласование проекта, документы. | + +### 3.7 `appointmentStatus` — статус записи +| Ключ | Русское название | Описание | +|---|---|---| +| `busy` | Занято | Слот забронирован, событие предстоит. | +| `done` | Завершено | Встреча прошла. | +| `noshow` | Не пришёл | Клиент не явился. | +| `canceled` | Отменено | Запись отменена. | + +> Статуса `free` нет: свободное время — это отсутствие записи, а не запись со статусом (см. раздел 5, `freeSlots`). + +### 3.8 `requestType` — тип заявки +| Ключ | Русское название | Описание | +|---|---|---| +| `supply` | Снабжение | Образцы, каталоги, материалы. | +| `escalate` | Эскалация | Конфликт/проблема, нужно вмешательство. | +| `visit` | Выезд/транспорт | Нужен ресурс для выезда к клиенту. | +| `schedule` | Расписание | Вопрос по сменам/графику. | +| `other` | Другое | Прочее. | + +### 3.9 `priority` — приоритет +| Ключ | Русское название | Описание | +|---|---|---| +| `low` | Низкий | Можно отложить. | +| `normal` | Обычный | Штатный. | +| `high` | Высокий | Срочно, влияет на деньги/клиента. | + +### 3.10 `requestStatus` — статус заявки +| Ключ | Русское название | Описание | +|---|---|---| +| `new` | Новая | Не обработана. | +| `inWork` | В работе | Взята в обработку. | +| `done` | Выполнена | Закрыта. | +| `rejected` | Отклонена | Отказано. | + +### 3.11 `shiftRequestType` — тип запроса по смене +| Ключ | Русское название | Описание | +|---|---|---| +| `swap` | Обмен сменами | С другим сотрудником (`withUserId` обязателен). | +| `off` | Отгул | Без замены (`withUserId = null`). | +| `extra` | Доп. смена | Просьба добавить смену. | + +### 3.12 `shiftRequestStatus` — статус запроса по смене +| Ключ | Русское название | Описание | +|---|---|---| +| `pending` | На рассмотрении | Ждёт решения администратора. | +| `approved` | Одобрено | Согласовано. | +| `declined` | Отклонено | Отказано. | + +### 3.13 `ratingStage` — этап заказа, для которого выставлена оценка +| Ключ | Название | +|---|---| +| `measuring` | Замер | +| `project` | Проект | +| `assembly` | Сборка | +| `order` | Весь заказ (клиентская оценка) | + +### 3.14 `ratingCriteria` — критерии оценки (ключи `scores`) +| Ключ | Название | Этапы | +|---|---|---| +| `quality` | Качество работы | measuring, project, assembly | +| `speed` | Скорость | measuring, assembly | +| `cleanliness` | Чистота | assembly | +| `deadlines` | Сроки | project, assembly | +| `communication` | Взаимодействие | measuring | +| `overall` | Общая оценка | order | +| `service` | Сервис | order | +| `result` | Результат | order | + +> Набор критериев в `ratings.scores` зависит от `stage`: Замер → `quality`, `speed`, `communication`; Проект → `quality`, `deadlines`; Сборка → `quality`, `speed`, `cleanliness`, `deadlines`; Весь заказ → `overall`, `service`, `result`. + +--- + +## 4. Сущность `shiftRequests` — запросы по сменам + +Заменяет `_SHIFT_REQS`. Вынесена отдельно от справочников для полноты. + +| Поле | Тип | Обяз. | Пример | Комментарий | +|---|---|:---:|---|---| +| `id` | string | да | `'shr_001'` | Префикс `shr_`. | +| `authorId` | string | да | `'usr_002'` | FK → `users.id`. Кто просит. | +| `salonId` | string | да | `'sal_lenina'` | FK → `salons.id`. | +| `type` | string | да | `'swap'` | Enum → `_DICT.shiftRequestType`. См. 3.11. | +| `withUserId` | string\|null | да | `'usr_003'` | FK → `users.id`. С кем обмен; `null` для `off`. | +| `dateFrom` | string (ISO date) | да | `'2026-05-31'` | Дата запроса. `YYYY-MM-DD`. Заменяет `'31 мая ↔ 1 июня'`. | +| `dateTo` | string (ISO date)\|null | нет | `'2026-06-01'` | Вторая дата для обмена; `null` если одна. | +| `status` | string | да | `'pending'` | Enum → `_DICT.shiftRequestStatus`. См. 3.12. | + +--- + +## 5. Вычисляемые поля (НЕ хранятся) + +Считаются на лету из хранимых данных. **Запрещено сохранять как поля сущностей** — это и есть «захардкоженный KPI» из аудита. Реализуются хелперами в `data.js`. + +### По менеджеру (`users` с `role='manager'`), за период +| Поле | Формула (словами) | Источник | +|---|---|---| +| `visits` | Кол-во `appointments` менеджера со `status='done'` за период | `appointments` | +| `deals` | Кол-во `orders` менеджера со `status='done'` за период | `orders` | +| `revenue` | Сумма `amount` закрытых заказов менеджера за период | `orders` | +| `conversion` | `deals / visits × 100`, % (0 если `visits=0`) | производное | +| `avg` | `revenue / deals`, средний чек (0 если `deals=0`) | производное | +| `commission` | По БП-5: ставка × сумма закрытых заказов | `orders` + `users` + `config` | +| `kpi` | Сводный балл; базово = `conversion`, сравнивается с `config.kpiNorm` | производное | +| `belowNorm` | `kpi < config.kpiNorm` → флаг | производное | + +### По сотруднику — рейтинг из оценок +| Поле | Формула (словами) | Источник | +|---|---|---| +| `employeeRating(userId, period)` | Среднее всех значений `scores` по всем `ratings` где `targetId=userId` за период | `ratings` | +| `orderRating(orderId)` | Клиентская оценка заказа: из `ratings` с `orderId`, `stage='order'` и `authorId = clientId` заказа | `ratings` | + +> Рейтинг сотрудника = среднее всех `scores` по всем его этапам за период (БП-15). Не хранится. + +### По салону (`salons`) +| Поле | Формула | Источник | +|---|---|---| +| `orders` | Кол-во заказов салона за месяц | `orders` по `salonId` | +| `revenue` | Сумма `amount` закрытых заказов салона за месяц | `orders` | +| `overdue` | Кол-во заказов с `isOverdue=true` | `orders` | +| `overdueRisk` | Сумма `amount` просроченных заказов | `orders` | +| `newLeads` | Кол-во `clients` со `status='lead'` за период | `clients` | +| `planFulfillment` | `revenue / revenuePlan × 100`, % | производное | +| `salonStatus` | `'ok'`/`'warn'`/`'risk'` по `planFulfillment` и `overdue` | производное | + +### По заказу (`orders`) +| Поле | Формула | Источник | +|---|---|---| +| `isOverdue` | `dueDate < сегодня` И `status ∉ {done, canceled}` | `orders` + дата | +| `balance` | `amount − prepayment`, остаток к оплате | производное | + +### По клиенту (`clients`) +| Поле | Формула | Источник | +|---|---|---| +| `ordersCount` | Кол-во заказов клиента | `orders` по `clientId` | +| `ltv` | Сумма `amount` закрытых заказов клиента | `orders` | + +### По расписанию +| Поле | Формула | Источник | +|---|---|---| +| `freeSlots` | Слоты рабочей сетки (`config.workdayStart..End` шагом `slotMinutes`) без `appointments` | производное | + +--- + +## 6. Бизнес-правила (утверждения, не код) + +- **БП-1. Единый формат времени.** Все моменты времени хранятся как ISO 8601 с таймзоной (`'2026-05-29T14:00:00+03:00'`); только-дата — как `YYYY-MM-DD`. Человекочитаемые строки (`'сегодня 09:14'`, `'31 мая'`) и сетки-слоты как ключи запрещены. Форматирование для UI — на стороне отображения. +- **БП-2. Заказ без клиента не существует.** `orders.clientId` обязателен и должен указывать на существующего `clients.id`. Создать заказ можно только после создания клиента. +- **БП-3. Длительность записи = разница времён.** `appointments.endAt > startAt`. Длительность не хранится отдельным полем. +- **БП-4. Согласованность салона.** `orders.salonId` должен совпадать с `clients.salonId` ответственного клиента. При смене салона клиента — пересобрать денормализацию. +- **БП-5. Комиссия только с закрытых заказов.** Начисляется лишь для `orderStatus='done'`. Ставка определяется в порядке приоритета: `orders.commissionRate` → `users.commissionRate` → `config.commissionRules[role].rate` (дефолт 7%). База — `orders.amount`. Результат — `getCommission(userId, period)` в разделе 5. +- **БП-6. Телефон клиента уникален в сети.** Дубликат телефона = тот же клиент. Повторное обращение не создаёт нового `clients`, а привязывается к существующему. +- **БП-7. KPI не хранится.** Любой показатель эффективности (раздел 5) вычисляется. Поле KPI в хранимой сущности — ошибка ревью. +- **БП-8. Enum только из `_DICT`.** Запись значения вне справочника недопустима. Новое значение → сначала в `_DICT`, потом в данные. +- **БП-9. id неизменяем и осмыслен по префиксу.** После создания `id` не меняется (на него ссылаются). Префиксы: `sal_`, `usr_`, `cli_`, `ord_`, `apt_`, `req_`, `shr_`, `rat_`. +- **БП-10. Удаление — мягкое.** Сотрудники/салоны/клиенты не удаляются физически (на них висит история заказов) — ставится `active=false` / `status='lost'`. Жёсткое удаление запрещено. +- **БП-11. Эскалация = смена адресата.** `requests` по умолчанию идёт администратору салона. КД видит её, только если `assigneeId` указывает на пользователя с `role='owner'` (эскалация) или `type='escalate'`. +- **БП-12. КД вне салона.** У `role='owner'` `salonId = null`. Любая выборка «по салону» для КД означает «по всем салонам». +- **БП-13. Клиентская оценка обязательна при закрытии.** Перевод заказа в статус `done` невозможен без клиентской оценки (`ratings` с `stage='order'`). Без неё закрыть заказ нельзя. +- **БП-14. Анонимность оценки для оцениваемого.** Оцениваемый (`targetId`) не видит `authorId`. Только пользователи с ролью `admin` и `owner` видят, кто оценил. +- **БП-15. Рейтинг сотрудника вычисляется.** Рейтинг = среднее всех `scores` по всем `ratings` где `targetId=userId` за период. Не хранится — вычисляется (раздел 5, `employeeRating`). + +--- + +## 7. Доступ по ролям + +Принцип: **менеджер видит своё, администратор — свой салон, КД — всю сеть. Замерщик и сборщик — только свои назначенные этапы.** + +| Сущность / данные | Менеджер (`manager`) | Замерщик (`measurer`) | Сборщик (`assembler`) | Администратор (`admin`) | КД (`owner`) | +|---|---|---|---|---|---| +| Свои клиенты | ✅ чтение/запись | ❌ | ❌ | ✅ все клиенты салона | ✅ все клиенты сети | +| Чужие клиенты | ❌ | ❌ | ❌ | ✅ в своём салоне | ✅ | +| Свои заказы | ✅ чтение/запись | ✅ только назначенные (этап замера) | ✅ только назначенные (этап сборки) | ✅ все заказы салона | ✅ все заказы сети | +| Чужие заказы | ❌ | ❌ | ❌ | ✅ свой салон | ✅ | +| Финансы заказа (`amount`, комиссия) | ✅ свои | ❌ | ❌ | ✅ свой салон | ✅ | +| Своё расписание (`appointments`) | ✅ чтение/запись | ✅ своё | ✅ своё | ✅ весь салон (шахматка) | ✅ все салоны | +| Свои KPI | ✅ только просмотр | ✅ только просмотр | ✅ только просмотр | ✅ KPI всех в салоне | ✅ KPI всей сети | +| Чужие KPI | ❌ | ❌ | ❌ | ✅ свой салон | ✅ | +| Оставлять оценки (`ratings`) | ✅ замерщика и сборщика | ✅ менеджера | ✅ менеджера | ✅ просмотр всех (видит `authorId`) | ✅ просмотр всех (видит `authorId`) | +| Получать оценки | ✅ от замерщика/сборщика | ✅ от менеджера и клиента | ✅ от менеджера и клиента | — | — | +| Видеть `authorId` оценки | ❌ (только итог) | ❌ (только итог) | ❌ (только итог) | ✅ | ✅ | +| Создание `requests` | ✅ создаёт | ✅ создаёт | ✅ создаёт | ✅ обрабатывает свой салон | ✅ видит эскалации | +| `shiftRequests` | ✅ создаёт свои | ✅ создаёт свои | ✅ создаёт свои | ✅ одобряет/отклоняет в салоне | ✅ просмотр | +| `config` / `commissionRules` | ❌ | ❌ | ❌ | ❌ просмотр своих ставок | ✅ полное управление | +| Управление `users` | ❌ | ❌ | ❌ | ✅ сотрудники своего салона | ✅ все, включая админов | +| Управление `salons` | ❌ | ❌ | ❌ | ❌ свой салон (чтение) | ✅ полное | +| Сводка по сети (все салоны) | ❌ | ❌ | ❌ | ❌ только свой салон | ✅ | + +> Замерщик и сборщик видят только свои назначенные заказы (конкретный этап) и своё расписание; финансовые данные заказа (сумма, комиссия) им недоступны. Оценки оставляют и получают по правилам раздела 1.8 и БП-13–15. + +> Разграничение в первом прототипе — на уровне фильтрации в UI по `currentUser.role` и `currentUser.salonId`. Серверной проверки нет (бэкенда нет) — это известное ограничение прототипа, не передавать в прод без авторизации на сервере. + +--- + +## 8. Скелет `data.js` (порядок и форма) + +```js +var _DICT = { role:{...}, orderStatus:{...}, ratingStage:{...}, ratingCriteria:{...}, /* ... разделы 3.1–3.14 */ }; + +var config = { + kpiNorm:80, currency:'RUB', timezone:'Europe/Moscow', + workdayStart:'10:00', workdayEnd:'20:00', slotMinutes:60, + commissionRules:[ {role:'manager', rate:0.07} ] // дефолт 7% +}; + +var salons = [ /* раздел 1.1 */ ]; +var users = [ /* раздел 1.2 */ ]; +var clients = [ /* раздел 1.3 */ ]; +var orders = [ /* раздел 1.4 — включая commissionRate */ ]; +var appointments = [ /* раздел 1.5 */ ]; +var requests = [ /* раздел 1.6 */ ]; +var shiftRequests = [ /* раздел 4 */ ]; +var ratings = [ /* раздел 1.8 */ ]; + +// Вычисляемые (раздел 5) — функции, НЕ поля: +function getMgrStats(userId, period){ /* visits/deals/revenue/conversion/avg/kpi */ } +function getSalonStats(salonId, period){ /* orders/revenue/overdue/... */ } +function isOverdue(order){ /* dueDate + status + сегодня */ } +function getFreeSlots(userId, date){ /* сетка минус appointments */ } +function getCommission(userId, period){ /* БП-5: orders.commissionRate → users.commissionRate → config */ } +function employeeRating(userId, period){ /* среднее scores по ratings.targetId=userId */ } +function orderRating(orderId){ /* клиентская оценка: ratings stage='order', authorId=clientId */ } +``` + +--- + +**Конец контракта.** При расхождении кода и этого документа — правится код. При расхождении документа и реальности — заводится правка документа, не молчаливый обход.