feat(scoring): formula v2 + P0 fixes (IDOR, reassign tasks, weights)

- P0 IDOR: GET/PATCH/history /leads/:id now enforce created_by for managers
  via shared checkLeadAccess() helper (parity with notes/tasks routes).
- P0 reassign: /leads/:id/reassign now updates lead_tasks.assigned_to for
  open tasks in the same CTE, so the bell/stats follow ownership.
- Scoring v2 (migration 1700000007000_scoring_v2.sql):
    * company_size 4 buckets (1-10/11-50/51-200/200+), weights 4/12/18/20
    * dm_contact MEDDIC ladder (unknown/user/influencer/champion/economic-buyer),
      weights 0/5/12/20/25
    * urgency 5 buckets (none/6m-plus/3-6m/1-3m/under-1m), weights 0/3/8/14/20
    * source 6 channels (cold/ad/content/referral/website/demo-request)
    * budget weights 0/8/15
    * email_mod ±5 (corporate domain vs free-mail) computed from leads.email
    * total clamped to [0..100]; theoretical max 95
    * score_history.formula_version tracks which formula generated each row
- Backend: types.ts/scoring.ts/leads.ts/intake.ts updated; calculateScore now
  takes ScoringInput.email; SCORING_FORMULA_VERSION exported.
- POST /admin/leads/recalc (rop only): bulk recompute over current formula,
  writes score_history only on delta, audit-logs the run.
- Frontend mirror at frontend/src/scoring.ts (weights, classifyEmail,
  previewScore) used by NewLeadView for live-score panel.
- UX: sticky score panel in form, per-button +N badges, earned/max per
  question, MEDDIC tooltips, email-mod chip near email field, admin
  "Пересчитать всё" button in /admin/users.
- Tests rewritten for v2: 18/18 passing.
- PRD/TRD updated to match v2 schema, weights, routes, and UI.
This commit is contained in:
Telecom 2026-05-22 11:40:47 +05:00
parent ef92acdd3f
commit 171fd4636c
16 changed files with 916 additions and 220 deletions

83
PRD.md
View File

@ -17,46 +17,64 @@
| Критерий | Inbound | Outbound | Макс. баллов | | Критерий | Inbound | Outbound | Макс. баллов |
|---|---|---|---| |---|---|---|---|
| Размер компании | ✅ | ✅ | 25 | | Размер компании | ✅ | ✅ | 20 |
| Подтверждён бюджет | ✅ | ✅ | 25 | | Кто наш контакт (MEDDIC) | ✅ | ✅ | 25 |
| Бюджет | ✅ | ✅ | 15 |
| Срочность / дедлайн | ✅ | ✅ | 20 | | Срочность / дедлайн | ✅ | ✅ | 20 |
| Контакт — ЛПР? | ✅ | ✅ | 15 |
| Источник лида | ✅ | — | 10 | | Источник лида | ✅ | — | 10 |
| Предыдущий контакт | — | ✅ | 10 | | Предыдущий контакт | — | ✅ | 10 |
| Соответствие ICP | ✅ | ✅ | 5 | | **Подсумма (вопросы формы)** | | | **90** |
| **Итого** | | | **100** | | Email-модификатор | ✅ | ✅ | ±5 (тихий) |
| **Итого, clamp [0..100]** | | | **95** |
> Критерий «Соответствие ICP» убран из формулы (см. миграцию `1700000004000_drop_icp_fit.sql`): ICP плохо квантуется в момент ввода и часто превращался в «галочку наугад».
>
> Формула v2 (см. `1700000007000_scoring_v2.sql`): размер компании теперь 4 уровня, шкала контакта расширена до MEDDIC-лестницы (5 уровней), срочность — 5 уровней, источник — 6 уровней. Email-домен добавляет ±5 как тихий модификатор (не требует ввода в форме). Score хранится с привязкой к `formula_version` в `score_history` — старые записи v1 сравниваются с новыми v2 корректно.
### Детальная логика баллов ### Детальная логика баллов
**Размер компании** **Размер компании** (0..20)
- 1–10 сотрудников → 5 баллов - 1–10 сотрудников → 4
- 11–50 сотрудников → 15 баллов - 11–50 → 12
- 50+ сотрудников → 25 баллов - 51–200 → 18
- 200+ → 20
**Бюджет** **Кто наш контакт?** (0..25, MEDDIC-лестница)
- Нет → 0 баллов - Не выяснено → 0
- Возможно → 12 баллов - Конечный пользователь (будет работать с продуктом, но не решает) → 5
- Подтверждён → 25 баллов - Влияет на выбор (слушают его мнение) → 12
- Внутренний адвокат (champion — продаёт за нас внутри) → 20
- Распорядитель бюджета (economic buyer — подпись на счёте) → 25
**Срочность** **Бюджет** (0..15)
- Нет дедлайна → 0 баллов - Нет → 0
- 3–6 месяцев → 10 баллов - Возможно → 8
- До 3 месяцев → 20 баллов - Подтверждён → 15
**Контакт — ЛПР?** **Срочность** (0..20)
- Нет → 0 баллов - Нет дедлайна → 0
- Влияет на решение → 8 баллов - Больше 6 месяцев → 3
- Принимает решение → 15 баллов - 3–6 месяцев → 8
- 1–3 месяца → 14
- Меньше месяца → 20
**Источник лида (только inbound)** **Источник лида** (только inbound, 0..10)
- Холодный → 3 балла - Холодный список → 2
- Реферал → 7 баллов - Реклама → 4
- Сайт / входящий → 10 баллов - Скачал материал → 6
- Рекомендация → 8
- Сайт / форма → 8
- Запросил демо → 10
**Предыдущий контакт (только outbound)** **Предыдущий контакт** (только outbound, 0..10)
- Не было → 0 баллов - Не было → 0
- Встречались один раз → 5 баллов - Встречались один раз → 5
- Знакомы хорошо → 10 баллов - Знакомы хорошо → 10
**Email-модификатор** (тихий, считается из домена)
- Корпоративный домен → +5
- Free-mail (gmail, yandex, mail.ru, hotmail, outlook, icloud, proton, …) → −5
- Email пустой → 0
--- ---
@ -66,7 +84,7 @@
|---|---|---| |---|---|---|
| 🔴 0–40 | Холодный | Не трать время сейчас | | 🔴 0–40 | Холодный | Не трать время сейчас |
| 🟡 41–70 | Тёплый | Nurture, следи за активностью | | 🟡 41–70 | Тёплый | Nurture, следи за активностью |
| 🟢 71–100 | Горячий | Звони сегодня | | 🟢 71–95 | Горячий | Звони сегодня |
--- ---
@ -84,7 +102,10 @@
- Имя контакта - Имя контакта
- Название компании - Название компании
- Тип лида: Inbound / Outbound (переключатель) - Тип лида: Inbound / Outbound (переключатель)
- 6 вопросов скоринга — **кнопки выбора, не текстовые поля** (быстро заполнять) - 5 вопросов скоринга — **кнопки выбора, не текстовые поля** (быстро заполнять)
- Над формой висит **живая полоса score**: меняется на каждый клик, цвет cold/warm/hot
- У каждой кнопки выбора видны баллы (`+12`, `+25`); у заголовка вопроса — `earned/max`
- Email-модификатор показывается значком рядом с полем email (`+5 корп` / `−5 free-mail`)
- Автоматический расчёт score при отправке - Автоматический расчёт score при отправке
### 3. Карточка лида ### 3. Карточка лида

92
TRD.md
View File

@ -43,20 +43,21 @@ Traefik ──► nginx (:80) ──► / ──► frontend :3000 (
### Таблица `leads` ### Таблица `leads`
```sql ```sql
-- Историческая справка: до миграции 1700000004000_drop_icp_fit столбец `icp_fit BOOLEAN NOT NULL`
-- также участвовал в скоринге (+5 баллов при true). Убран — плохо квантовался при вводе.
CREATE TABLE leads ( CREATE TABLE leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_name TEXT NOT NULL, contact_name TEXT NOT NULL,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
lead_type TEXT NOT NULL CHECK (lead_type IN ('inbound', 'outbound')), lead_type TEXT NOT NULL CHECK (lead_type IN ('inbound', 'outbound')),
-- ответы на вопросы скоринга -- ответы на вопросы скоринга (формула v2, см. миграцию 1700000007000_scoring_v2.sql)
company_size TEXT NOT NULL CHECK (company_size IN ('1-10', '11-50', '50+')), company_size TEXT NOT NULL CHECK (company_size IN ('1-10', '11-50', '51-200', '200+')),
budget TEXT NOT NULL CHECK (budget IN ('no', 'maybe', 'confirmed')), budget TEXT NOT NULL CHECK (budget IN ('no', 'maybe', 'confirmed')),
urgency TEXT NOT NULL CHECK (urgency IN ('none', '3-6m', 'under-3m')), urgency TEXT NOT NULL CHECK (urgency IN ('none', '6m-plus', '3-6m', '1-3m', 'under-1m')),
dm_contact TEXT NOT NULL CHECK (dm_contact IN ('no', 'influencer', 'decision-maker')), dm_contact TEXT NOT NULL CHECK (dm_contact IN ('unknown', 'user', 'influencer', 'champion', 'economic-buyer')),
source TEXT CHECK (source IN ('cold', 'referral', 'website')), -- только inbound source TEXT CHECK (source IN ('cold', 'ad', 'content', 'referral', 'website', 'demo-request')), -- только inbound
prev_contact TEXT CHECK (prev_contact IN ('none', 'once', 'well-known')), -- только outbound prev_contact TEXT CHECK (prev_contact IN ('none', 'once', 'well-known')), -- только outbound
icp_fit BOOLEAN NOT NULL,
-- computed -- computed
score INT NOT NULL CHECK (score BETWEEN 0 AND 100), score INT NOT NULL CHECK (score BETWEEN 0 AND 100),
@ -85,7 +86,8 @@ CREATE TABLE score_history (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE, lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
total INT NOT NULL, total INT NOT NULL,
breakdown JSONB NOT NULL, -- {company_size: 15, budget: 25, ...} breakdown JSONB NOT NULL, -- {company_size: 18, dm_contact: 20, email_mod: 5, ...}
formula_version INT NOT NULL DEFAULT 2,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
changed_by TEXT NOT NULL changed_by TEXT NOT NULL
); );
@ -93,7 +95,12 @@ CREATE TABLE score_history (
CREATE INDEX score_history_lead ON score_history (lead_id, changed_at DESC); CREATE INDEX score_history_lead ON score_history (lead_id, changed_at DESC);
``` ```
Запись в `score_history` добавляется на каждый `POST /leads` и каждый `PATCH /leads/:id`, который меняет score. Запись в `score_history` добавляется на:
- каждый `POST /leads`,
- каждый `PATCH /leads/:id`, который меняет score,
- каждый прогон `POST /admin/leads/recalc` (только для лидов, чей итоговый score изменился).
`formula_version` фиксирует, по какой версии скоринг-формулы посчитан балл — даёт возможность сравнивать историю после смены весов.
### Пользователи ### Пользователи
@ -115,33 +122,41 @@ JWT_SECRET=<random-32-bytes-base64>
```ts ```ts
type ScoringInput = { type ScoringInput = {
lead_type: 'inbound' | 'outbound'; lead_type: 'inbound' | 'outbound';
company_size: '1-10' | '11-50' | '50+'; company_size: '1-10' | '11-50' | '51-200' | '200+';
budget: 'no' | 'maybe' | 'confirmed'; budget: 'no' | 'maybe' | 'confirmed';
urgency: 'none' | '3-6m' | 'under-3m'; urgency: 'none' | '6m-plus' | '3-6m' | '1-3m' | 'under-1m';
dm_contact: 'no' | 'influencer' | 'decision-maker'; dm_contact: 'unknown' | 'user' | 'influencer' | 'champion' | 'economic-buyer';
source?: 'cold' | 'referral' | 'website'; // только inbound source?: 'cold' | 'ad' | 'content' | 'referral' | 'website' | 'demo-request'; // только inbound
prev_contact?: 'none' | 'once' | 'well-known'; // только outbound prev_contact?: 'none' | 'once' | 'well-known'; // только outbound
icp_fit: boolean; email?: string | null; // используется для email-модификатора (±5)
}; };
type ScoringResult = { type ScoringResult = {
total: number; // 0..100 total: number; // clamp [0..100]; теоретический максимум формулы — 95
breakdown: Record<string, number>; // каждый критерий → баллы breakdown: Record<string, number>;
status: 'cold' | 'warm' | 'hot'; status: 'cold' | 'warm' | 'hot';
}; };
export const SCORING_FORMULA_VERSION = 2;
``` ```
### Таблицы баллов (из PRD §«Детальная логика») ### Таблицы баллов (из PRD §«Детальная логика»)
| Критерий | Значения → баллы | | Критерий | Max | Значения → баллы |
|---|---| |---|---|---|
| `company_size` | `1-10`→5, `11-50`→15, `50+`→25 | | `company_size` | 20 | `1-10`→4, `11-50`→12, `51-200`→18, `200+`→20 |
| `budget` | `no`→0, `maybe`→12, `confirmed`→25 | | `dm_contact` | 25 | `unknown`→0, `user`→5, `influencer`→12, `champion`→20, `economic-buyer`→25 |
| `urgency` | `none`→0, `3-6m`→10, `under-3m`→20 | | `budget` | 15 | `no`→0, `maybe`→8, `confirmed`→15 |
| `dm_contact` | `no`→0, `influencer`→8, `decision-maker`→15 | | `urgency` | 20 | `none`→0, `6m-plus`→3, `3-6m`→8, `1-3m`→14, `under-1m`→20 |
| `source` (inbound) | `cold`→3, `referral`→7, `website`→10 | | `source` (inbound) | 10 | `cold`→2, `ad`→4, `content`→6, `referral`→8, `website`→8, `demo-request`→10 |
| `prev_contact` (outbound) | `none`→0, `once`→5, `well-known`→10 | | `prev_contact` (outbound) | 10 | `none`→0, `once`→5, `well-known`→10 |
| `icp_fit` | `true`→5, `false`→0 | | `email_mod` (тихий, из домена) | ±5 | корп. → +5, free-mail → −5, пусто → 0 |
Сумма вопросов формы: **90** (inbound = 20+25+15+20+10, outbound = 20+25+15+20+10).
С email-модификатором: **95** макс.
Итог clamp в `[0..100]` на случай будущих расширений.
Список free-mail доменов хранится в `backend/src/scoring.ts → FREE_MAIL_DOMAINS`. Зеркало для live-предпросмотра в форме — `frontend/src/scoring.ts`. **При смене весов нужно править обе копии.**
### Пороги статуса ### Пороги статуса
@ -174,14 +189,14 @@ JWT payload: `{sub: username, role: username, iat, exp}`. TTL токена — 1
| Метод | Путь | Кто | Описание | | Метод | Путь | Кто | Описание |
|---|---|---|---| |---|---|---|---|
| GET | `/api/leads` | любой | Список. Query: `status`, `lead_type`, `sort` (default `score_desc`), `limit`, `offset`. Возвращает `{data: Lead[], total}`. | | GET | `/api/leads` | любой | Список. Query: `status`, `lead_type`, `sort` (default `score_desc`), `limit`, `offset`. Менеджер видит только свои; РОП — все. Возвращает `{data: Lead[], total}`. |
| POST | `/api/leads` | любой | Создать. Body — форма PRD §2 + 6 ответов. Score считается на сервере, игнорируется если пришёл в теле. Пишет запись в `score_history`. | | POST | `/api/leads` | любой | Создать. Body — форма PRD §2 + 5 ответов скоринга. Score (с учётом email-модификатора) считается на сервере, игнорируется если пришёл в теле. Пишет запись в `score_history`. |
| GET | `/api/leads/:id` | любой | Полная карточка (lead + последний breakdown). | | GET | `/api/leads/:id` | свой или `rop` | Полная карточка (lead + последний breakdown). Менеджер получает 403, если лид не его. |
| PATCH | `/api/leads/:id` | любой | Редактирование. Пересчитывает score. Если score меняется → запись в `score_history`. | | PATCH | `/api/leads/:id` | свой или `rop` | Редактирование. Пересчитывает score. Если score меняется → запись в `score_history`. |
| GET | `/api/leads/:id/history` | любой | История изменений score. | | GET | `/api/leads/:id/history` | свой или `rop` | История изменений score. |
| DELETE | `/api/leads/:id` | только `rop` | Удалить. | | DELETE | `/api/leads/:id` | только `rop` | Удалить. |
| POST | `/api/leads/:id/reassign` | только `rop` | Body `{assigned_to}`. В одной транзакции переназначает владельца лида и все его открытые задачи. |
Роль `rop` отличается только правом на DELETE (задел; если не нужно — скажи, выкинем). | POST | `/api/admin/leads/recalc` | только `rop` | Bulk-пересчёт score у всех лидов под текущую формулу. Пишет в `score_history` только для лидов, у кого total изменился. Возвращает `{total, updated, formula_version}`. |
### Ответ лида (пример) ### Ответ лида (пример)
@ -192,14 +207,14 @@ JWT payload: `{sub: username, role: username, iat, exp}`. TTL токена — 1
"company_name": "ООО Ромашка", "company_name": "ООО Ромашка",
"lead_type": "inbound", "lead_type": "inbound",
"answers": { "answers": {
"company_size": "50+", "budget": "confirmed", "urgency": "under-3m", "company_size": "200+", "budget": "confirmed", "urgency": "under-1m",
"dm_contact": "decision-maker", "source": "website", "icp_fit": true "dm_contact": "economic-buyer", "source": "demo-request"
}, },
"score": 100, "score": 95,
"status": "hot", "status": "hot",
"breakdown": { "breakdown": {
"company_size": 25, "budget": 25, "urgency": 20, "company_size": 20, "dm_contact": 25, "budget": 15,
"dm_contact": 15, "source": 10, "icp_fit": 5 "urgency": 20, "source": 10, "email_mod": 5
}, },
"created_at": "2026-04-24T10:00:00Z", "created_at": "2026-04-24T10:00:00Z",
"updated_at": "2026-04-24T10:00:00Z", "updated_at": "2026-04-24T10:00:00Z",
@ -270,6 +285,11 @@ frontend/
- В форме — **только кнопки выбора** (radio-группы, никаких текстовых полей для ответов). Имя и компания — текст. - В форме — **только кнопки выбора** (radio-группы, никаких текстовых полей для ответов). Имя и компания — текст.
- Поля формы, зависящие от `lead_type`, показываются/скрываются реактивно: inbound → поле «Источник», outbound → «Предыдущий контакт». - Поля формы, зависящие от `lead_type`, показываются/скрываются реактивно: inbound → поле «Источник», outbound → «Предыдущий контакт».
- На карточке лида — breakdown по каждому критерию (баллы) + рекомендация (`cold`: «Не трать время сейчас», `warm`: «Nurture, следи за активностью», `hot`: «Звони сегодня»). - На карточке лида — breakdown по каждому критерию (баллы) + рекомендация (`cold`: «Не трать время сейчас», `warm`: «Nurture, следи за активностью», `hot`: «Звони сегодня»).
- **Live-score panel** в форме создания: липкий блок сверху с цифрой балла, цветом и полосой прогресса; обновляется на каждый клик через `previewScore()` из `frontend/src/scoring.ts`.
- На каждой кнопке выбора видна цифра баллов (`+12`), у заголовка вопроса — `earned/max`.
- У поля email — значок модификатора (`+5 корп` / `−5 free-mail`), пересчитывается на blur/input.
- На MEDDIC-кнопках («Не выяснено», «Конечный пользователь», «Влияет на выбор», «Внутренний адвокат», «Распорядитель бюджета») — `title`-tooltip с пояснением термина.
- В админке (`/admin/users`) — кнопка **«Пересчитать всё»** для ропа: дёргает `POST /api/admin/leads/recalc`, показывает toast `Пересчитано: X из Y (formula vN)`.
### API base URL ### API base URL

View File

@ -10,6 +10,9 @@ import {
} from '../auth.js'; } from '../auth.js';
import { audit } from '../audit.js'; import { audit } from '../audit.js';
import { AppError } from '../errors.js'; import { AppError } from '../errors.js';
import { query } from '../db.js';
import { calculateScore, SCORING_FORMULA_VERSION } from '../scoring.js';
import type { LeadRow, ScoringInput } from '../types.js';
function parseRole(v: unknown): 'manager' | 'rop' { function parseRole(v: unknown): 'manager' | 'rop' {
if (v === 'manager' || v === 'rop') return v; if (v === 'manager' || v === 'rop') return v;
@ -77,4 +80,48 @@ export async function adminRoutes(app: FastifyInstance) {
await audit(user.sub, 'user', req.params.username, 'delete'); await audit(user.sub, 'user', req.params.username, 'delete');
return reply.code(204).send(); return reply.code(204).send();
}); });
// Bulk recompute score on all leads using the current formula.
// Writes a score_history row only when the new score differs from the stored one.
app.post('/admin/leads/recalc', async (req) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
const { rows } = await query<LeadRow>(`SELECT * FROM leads`);
let updated = 0;
for (const row of rows) {
const input: ScoringInput = {
lead_type: row.lead_type,
company_size: row.company_size,
budget: row.budget,
urgency: row.urgency,
dm_contact: row.dm_contact,
source: row.source ?? undefined,
prev_contact: row.prev_contact ?? undefined,
email: row.email || null,
};
const { total, breakdown, status } = calculateScore(input);
if (total === row.score && status === row.status) continue;
await query(
`UPDATE leads SET score = $1, status = $2, updated_at = NOW() WHERE id = $3`,
[total, status, row.id],
);
await query(
`INSERT INTO score_history (lead_id, total, breakdown, changed_by, formula_version)
VALUES ($1, $2, $3, $4, $5)`,
[row.id, total, breakdown, user.sub, SCORING_FORMULA_VERSION],
);
updated++;
}
await audit(user.sub, 'system', 'leads', 'recalc', {
total: rows.length,
updated,
formula_version: SCORING_FORMULA_VERSION,
});
return { total: rows.length, updated, formula_version: SCORING_FORMULA_VERSION };
});
} }

View File

@ -105,17 +105,25 @@ export async function intakeRoutes(app: FastifyInstance) {
const lead_type = pickEnum<LeadType>(body.lead_type, ['inbound', 'outbound'], 'inbound'); const lead_type = pickEnum<LeadType>(body.lead_type, ['inbound', 'outbound'], 'inbound');
const company_size = pickEnum<CompanySize>( const company_size = pickEnum<CompanySize>(
body.company_size, body.company_size,
['1-10', '11-50', '50+'], ['1-10', '11-50', '51-200', '200+'],
'1-10', '1-10',
); );
const budget = pickEnum<Budget>(body.budget, ['no', 'maybe', 'confirmed'], 'maybe'); const budget = pickEnum<Budget>(body.budget, ['no', 'maybe', 'confirmed'], 'maybe');
const urgency = pickEnum<Urgency>(body.urgency, ['none', '3-6m', 'under-3m'], 'none'); const urgency = pickEnum<Urgency>(
body.urgency,
['none', '6m-plus', '3-6m', '1-3m', 'under-1m'],
'none',
);
const dm_contact = pickEnum<DmContact>( const dm_contact = pickEnum<DmContact>(
body.dm_contact, body.dm_contact,
['no', 'influencer', 'decision-maker'], ['unknown', 'user', 'influencer', 'champion', 'economic-buyer'],
'no', 'unknown',
);
const source = pickEnum<Source>(
body.source,
['cold', 'ad', 'content', 'referral', 'website', 'demo-request'],
'website',
); );
const source = pickEnum<Source>(body.source, ['cold', 'referral', 'website'], 'website');
const prev_contact = pickEnum<PrevContact>( const prev_contact = pickEnum<PrevContact>(
body.prev_contact, body.prev_contact,
['none', 'once', 'well-known'], ['none', 'once', 'well-known'],
@ -130,6 +138,7 @@ export async function intakeRoutes(app: FastifyInstance) {
dm_contact, dm_contact,
source: lead_type === 'inbound' ? source : undefined, source: lead_type === 'inbound' ? source : undefined,
prev_contact: lead_type === 'outbound' ? prev_contact : undefined, prev_contact: lead_type === 'outbound' ? prev_contact : undefined,
email: emailRaw || null,
}; };
const { total, status } = calculateScore(scoring); const { total, status } = calculateScore(scoring);

View File

@ -80,7 +80,7 @@ function parseDateOnly(v: unknown): string | null {
return `${m[1]}-${m[2]}-${m[3]}`; return `${m[1]}-${m[2]}-${m[3]}`;
} }
function parseScoringFromBody(body: LeadBody): ScoringInput { function parseScoringFromBody(body: LeadBody, email: string | null): ScoringInput {
const lead_type = body.lead_type; const lead_type = body.lead_type;
if (lead_type !== 'inbound' && lead_type !== 'outbound') { if (lead_type !== 'inbound' && lead_type !== 'outbound') {
throw AppError.validation('lead_type must be inbound or outbound', { throw AppError.validation('lead_type must be inbound or outbound', {
@ -93,6 +93,7 @@ function parseScoringFromBody(body: LeadBody): ScoringInput {
budget: body.budget as ScoringInput['budget'], budget: body.budget as ScoringInput['budget'],
urgency: body.urgency as ScoringInput['urgency'], urgency: body.urgency as ScoringInput['urgency'],
dm_contact: body.dm_contact as ScoringInput['dm_contact'], dm_contact: body.dm_contact as ScoringInput['dm_contact'],
email,
}; };
if (lead_type === 'inbound') { if (lead_type === 'inbound') {
if (body.source !== undefined) input.source = body.source as ScoringInput['source']; if (body.source !== undefined) input.source = body.source as ScoringInput['source'];
@ -155,6 +156,18 @@ async function latestBreakdown(leadId: string): Promise<Record<string, number>>
return rows[0]?.breakdown ?? {}; return rows[0]?.breakdown ?? {};
} }
async function checkLeadAccess(
leadId: string,
user: { sub: string; role: string },
): Promise<LeadRow> {
const { rows } = await query<LeadRow>(`SELECT * FROM leads WHERE id = $1`, [leadId]);
if (rows.length === 0) throw AppError.notFound('Lead not found');
if (user.role !== 'rop' && rows[0].created_by !== user.sub) {
throw AppError.forbidden('Not your lead');
}
return rows[0];
}
export async function leadRoutes(app: FastifyInstance) { export async function leadRoutes(app: FastifyInstance) {
app.addHook('onRequest', async (req) => { app.addHook('onRequest', async (req) => {
await requireAuth(app, req); await requireAuth(app, req);
@ -377,7 +390,7 @@ export async function leadRoutes(app: FastifyInstance) {
const expected_amount = parseAmount(body.expected_amount); const expected_amount = parseAmount(body.expected_amount);
const next_action_date = parseDateOnly(body.next_action_date); const next_action_date = parseDateOnly(body.next_action_date);
const input = parseScoringFromBody(body); const input = parseScoringFromBody(body, email || null);
const { total, breakdown, status } = calculateScore(input); const { total, breakdown, status } = calculateScore(input);
const { rows } = await query<LeadRow>( const { rows } = await query<LeadRow>(
@ -424,22 +437,16 @@ export async function leadRoutes(app: FastifyInstance) {
// GET BY ID // GET BY ID
app.get<{ Params: { id: string } }>('/leads/:id', async (req) => { app.get<{ Params: { id: string } }>('/leads/:id', async (req) => {
const { rows } = await query<LeadRow>(`SELECT * FROM leads WHERE id = $1`, [req.params.id]); const user = await requireAuth(app, req);
const lead = rows[0]; const lead = await checkLeadAccess(req.params.id, user);
if (!lead) throw AppError.notFound('Lead not found');
const breakdown = await latestBreakdown(lead.id); const breakdown = await latestBreakdown(lead.id);
return serializeLead(lead, breakdown); return serializeLead(lead, breakdown);
}); });
// PATCH // PATCH
app.patch<{ Params: { id: string }; Body: LeadBody }>('/leads/:id', async (req) => { app.patch<{ Params: { id: string }; Body: LeadBody }>('/leads/:id', async (req) => {
const user = req.user as { sub: string }; const user = await requireAuth(app, req);
const { rows: currentRows } = await query<LeadRow>( const current = await checkLeadAccess(req.params.id, user);
`SELECT * FROM leads WHERE id = $1`,
[req.params.id],
);
const current = currentRows[0];
if (!current) throw AppError.notFound('Lead not found');
const body = req.body ?? {}; const body = req.body ?? {};
@ -481,6 +488,7 @@ export async function leadRoutes(app: FastifyInstance) {
budget: (body.budget ?? current.budget) as ScoringInput['budget'], budget: (body.budget ?? current.budget) as ScoringInput['budget'],
urgency: (body.urgency ?? current.urgency) as ScoringInput['urgency'], urgency: (body.urgency ?? current.urgency) as ScoringInput['urgency'],
dm_contact: (body.dm_contact ?? current.dm_contact) as ScoringInput['dm_contact'], dm_contact: (body.dm_contact ?? current.dm_contact) as ScoringInput['dm_contact'],
email: email || null,
}; };
if (lead_type === 'inbound') { if (lead_type === 'inbound') {
const src = body.source ?? current.source; const src = body.source ?? current.source;
@ -542,8 +550,8 @@ export async function leadRoutes(app: FastifyInstance) {
// HISTORY // HISTORY
app.get<{ Params: { id: string } }>('/leads/:id/history', async (req) => { app.get<{ Params: { id: string } }>('/leads/:id/history', async (req) => {
const { rowCount } = await query(`SELECT 1 FROM leads WHERE id = $1`, [req.params.id]); const user = await requireAuth(app, req);
if (!rowCount) throw AppError.notFound('Lead not found'); await checkLeadAccess(req.params.id, user);
const { rows } = await query<{ const { rows } = await query<{
id: string; id: string;
@ -586,11 +594,20 @@ export async function leadRoutes(app: FastifyInstance) {
const { rows } = await query<LeadRow & { prev: string }>( const { rows } = await query<LeadRow & { prev: string }>(
`WITH cur AS ( `WITH cur AS (
SELECT created_by AS prev FROM leads WHERE id = $1 SELECT created_by AS prev FROM leads WHERE id = $1
) ),
upd_lead AS (
UPDATE leads UPDATE leads
SET created_by = $2, updated_at = NOW() SET created_by = $2, updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING *, (SELECT prev FROM cur) AS prev`, RETURNING *, (SELECT prev FROM cur) AS prev
),
upd_tasks AS (
UPDATE lead_tasks
SET assigned_to = $2
WHERE lead_id = $1 AND completed_at IS NULL
RETURNING 1
)
SELECT * FROM upd_lead`,
[req.params.id, target], [req.params.id, target],
); );
if (rows.length === 0) throw AppError.notFound('Lead not found'); if (rows.length === 0) throw AppError.notFound('Lead not found');

View File

@ -11,12 +11,110 @@ import type {
} from './types.js'; } from './types.js';
import { AppError } from './errors.js'; import { AppError } from './errors.js';
const COMPANY_SIZE: Record<CompanySize, number> = { '1-10': 5, '11-50': 15, '50+': 25 }; export const SCORING_FORMULA_VERSION = 2;
const BUDGET: Record<Budget, number> = { no: 0, maybe: 12, confirmed: 25 };
const URGENCY: Record<Urgency, number> = { none: 0, '3-6m': 10, 'under-3m': 20 }; const COMPANY_SIZE: Record<CompanySize, number> = {
const DM_CONTACT: Record<DmContact, number> = { no: 0, influencer: 10, 'decision-maker': 20 }; '1-10': 4,
const SOURCE: Record<Source, number> = { cold: 3, referral: 7, website: 10 }; '11-50': 12,
const PREV_CONTACT: Record<PrevContact, number> = { none: 0, once: 5, 'well-known': 10 }; '51-200': 18,
'200+': 20,
};
const BUDGET: Record<Budget, number> = { no: 0, maybe: 8, confirmed: 15 };
const URGENCY: Record<Urgency, number> = {
none: 0,
'6m-plus': 3,
'3-6m': 8,
'1-3m': 14,
'under-1m': 20,
};
const DM_CONTACT: Record<DmContact, number> = {
unknown: 0,
user: 5,
influencer: 12,
champion: 20,
'economic-buyer': 25,
};
const SOURCE: Record<Source, number> = {
cold: 2,
ad: 4,
content: 6,
referral: 8,
website: 8,
'demo-request': 10,
};
const PREV_CONTACT: Record<PrevContact, number> = {
none: 0,
once: 5,
'well-known': 10,
};
export const SCORING_MAX = {
company_size: 20,
dm_contact: 25,
budget: 15,
urgency: 20,
source: 10,
prev_contact: 10,
email_mod: 5,
} as const;
const FREE_MAIL_DOMAINS = new Set([
'gmail.com',
'googlemail.com',
'yahoo.com',
'yahoo.co.uk',
'hotmail.com',
'outlook.com',
'live.com',
'msn.com',
'icloud.com',
'me.com',
'mac.com',
'aol.com',
'mail.ru',
'inbox.ru',
'list.ru',
'bk.ru',
'internet.ru',
'yandex.ru',
'yandex.com',
'ya.ru',
'rambler.ru',
'protonmail.com',
'proton.me',
'gmx.com',
'gmx.net',
'zoho.com',
'tutanota.com',
'fastmail.com',
]);
function emailDomain(email: string | null | undefined): string {
if (!email || typeof email !== 'string') return '';
const at = email.lastIndexOf('@');
if (at < 0) return '';
return email.slice(at + 1).trim().toLowerCase();
}
export function classifyEmail(
email: string | null | undefined,
): 'corporate' | 'free' | 'empty' {
const d = emailDomain(email);
if (!d || !d.includes('.')) return 'empty';
return FREE_MAIL_DOMAINS.has(d) ? 'free' : 'corporate';
}
function emailMod(email: string | null | undefined): number {
const cls = classifyEmail(email);
if (cls === 'corporate') return 5;
if (cls === 'free') return -5;
return 0;
}
export function validateScoringInput(input: ScoringInput): void { export function validateScoringInput(input: ScoringInput): void {
const fields: Record<string, string> = {}; const fields: Record<string, string> = {};
@ -54,9 +152,9 @@ export function calculateScore(input: ScoringInput): ScoringResult {
const breakdown: Record<string, number> = { const breakdown: Record<string, number> = {
company_size: COMPANY_SIZE[input.company_size], company_size: COMPANY_SIZE[input.company_size],
dm_contact: DM_CONTACT[input.dm_contact],
budget: BUDGET[input.budget], budget: BUDGET[input.budget],
urgency: URGENCY[input.urgency], urgency: URGENCY[input.urgency],
dm_contact: DM_CONTACT[input.dm_contact],
}; };
if (input.lead_type === 'inbound' && input.source) { if (input.lead_type === 'inbound' && input.source) {
@ -65,7 +163,11 @@ export function calculateScore(input: ScoringInput): ScoringResult {
breakdown.prev_contact = PREV_CONTACT[input.prev_contact]; breakdown.prev_contact = PREV_CONTACT[input.prev_contact];
} }
const total = Object.values(breakdown).reduce((a, b) => a + b, 0); const mod = emailMod(input.email);
if (mod !== 0) breakdown.email_mod = mod;
const rawTotal = Object.values(breakdown).reduce((a, b) => a + b, 0);
const total = Math.max(0, Math.min(100, rawTotal));
return { total, breakdown, status: statusFor(total) }; return { total, breakdown, status: statusFor(total) };
} }

View File

@ -1,9 +1,9 @@
export type LeadType = 'inbound' | 'outbound'; export type LeadType = 'inbound' | 'outbound';
export type CompanySize = '1-10' | '11-50' | '50+'; export type CompanySize = '1-10' | '11-50' | '51-200' | '200+';
export type Budget = 'no' | 'maybe' | 'confirmed'; export type Budget = 'no' | 'maybe' | 'confirmed';
export type Urgency = 'none' | '3-6m' | 'under-3m'; export type Urgency = 'none' | '6m-plus' | '3-6m' | '1-3m' | 'under-1m';
export type DmContact = 'no' | 'influencer' | 'decision-maker'; export type DmContact = 'unknown' | 'user' | 'influencer' | 'champion' | 'economic-buyer';
export type Source = 'cold' | 'referral' | 'website'; export type Source = 'cold' | 'ad' | 'content' | 'referral' | 'website' | 'demo-request';
export type PrevContact = 'none' | 'once' | 'well-known'; export type PrevContact = 'none' | 'once' | 'well-known';
export type LeadStatus = 'cold' | 'warm' | 'hot'; export type LeadStatus = 'cold' | 'warm' | 'hot';
export type DealStage = export type DealStage =
@ -29,6 +29,7 @@ export interface ScoringInput {
dm_contact: DmContact; dm_contact: DmContact;
source?: Source; source?: Source;
prev_contact?: PrevContact; prev_contact?: PrevContact;
email?: string | null;
} }
export interface ScoringResult { export interface ScoringResult {

View File

@ -1,14 +1,18 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { calculateScore, validateScoringInput } from '../src/scoring.js'; import {
calculateScore,
classifyEmail,
validateScoringInput,
} from '../src/scoring.js';
import type { ScoringInput } from '../src/types.js'; import type { ScoringInput } from '../src/types.js';
const baseInbound: ScoringInput = { const baseInbound: ScoringInput = {
lead_type: 'inbound', lead_type: 'inbound',
company_size: '50+', company_size: '200+',
budget: 'confirmed', budget: 'confirmed',
urgency: 'under-3m', urgency: 'under-1m',
dm_contact: 'decision-maker', dm_contact: 'economic-buyer',
source: 'website', source: 'demo-request',
}; };
const baseOutbound: ScoringInput = { const baseOutbound: ScoringInput = {
@ -16,89 +20,118 @@ const baseOutbound: ScoringInput = {
company_size: '1-10', company_size: '1-10',
budget: 'no', budget: 'no',
urgency: 'none', urgency: 'none',
dm_contact: 'no', dm_contact: 'unknown',
prev_contact: 'none', prev_contact: 'none',
}; };
describe('calculateScore', () => { describe('calculateScore', () => {
it('max inbound → 100, hot', () => { it('max inbound (corporate email) → 95, hot', () => {
const r = calculateScore(baseInbound); const r = calculateScore({ ...baseInbound, email: 'ivan@acme.com' });
expect(r.total).toBe(100); expect(r.total).toBe(95);
expect(r.status).toBe('hot'); expect(r.status).toBe('hot');
expect(r.breakdown).toEqual({ expect(r.breakdown).toEqual({
company_size: 25, company_size: 20,
budget: 25, dm_contact: 25,
budget: 15,
urgency: 20, urgency: 20,
dm_contact: 20,
source: 10, source: 10,
email_mod: 5,
}); });
}); });
it('min outbound → 5, cold', () => { it('max inbound (no email) → 90, hot', () => {
const r = calculateScore(baseInbound);
expect(r.total).toBe(90);
expect(r.status).toBe('hot');
expect(r.breakdown.email_mod).toBeUndefined();
});
it('free-mail penalty subtracts 5', () => {
const r = calculateScore({ ...baseInbound, email: 'someone@gmail.com' });
expect(r.breakdown.email_mod).toBe(-5);
expect(r.total).toBe(85);
});
it('min outbound → 4, cold', () => {
const r = calculateScore(baseOutbound); const r = calculateScore(baseOutbound);
expect(r.total).toBe(5); expect(r.total).toBe(4);
expect(r.status).toBe('cold'); expect(r.status).toBe('cold');
expect(r.breakdown).toEqual({ expect(r.breakdown).toEqual({
company_size: 5, company_size: 4,
dm_contact: 0,
budget: 0, budget: 0,
urgency: 0, urgency: 0,
dm_contact: 0,
prev_contact: 0, prev_contact: 0,
}); });
expect(Object.values(r.breakdown).reduce((a, b) => a + b, 0)).toBe(r.total);
}); });
it('warm — 44 is warm', () => { it('warm inbound — 53', () => {
const r = calculateScore({ const r = calculateScore({
lead_type: 'inbound', lead_type: 'inbound',
company_size: '11-50', // 15 company_size: '11-50', // 12
budget: 'maybe', // 12 budget: 'maybe', // 8
urgency: '3-6m', // 10 urgency: '3-6m', // 8
dm_contact: 'no', // 0 dm_contact: 'influencer', // 12
source: 'referral', // 7 source: 'referral', // 8
email: 'ceo@startup.co', // +5 corp
}); });
expect(r.total).toBe(44); expect(r.total).toBe(53);
expect(r.status).toBe('warm'); expect(r.status).toBe('warm');
}); });
it('cold boundary — 37 is cold', () => { it('cold boundary — 40 is cold, 41 warm', () => {
const r = calculateScore({ const cold = calculateScore({
lead_type: 'outbound', lead_type: 'outbound',
company_size: '11-50', // 15 company_size: '51-200', // 18
budget: 'maybe', // 12 budget: 'maybe', // 8
urgency: '3-6m', // 10 urgency: '3-6m', // 8
dm_contact: 'no', // 0 dm_contact: 'unknown', // 0
prev_contact: 'none', // 0 prev_contact: 'once', // 5
}); });
expect(r.total).toBe(37); expect(cold.total).toBe(39);
expect(r.status).toBe('cold'); expect(cold.status).toBe('cold');
const warm = calculateScore({
lead_type: 'outbound',
company_size: '51-200', // 18
budget: 'confirmed', // 15
urgency: '3-6m', // 8
dm_contact: 'unknown', // 0
prev_contact: 'once', // 5
});
expect(warm.total).toBe(46);
expect(warm.status).toBe('warm');
}); });
it('hot — 80 is hot', () => { it('clamps total to 100 max', () => {
// synthetic ceiling test: max ever achievable is 95, so test the clamp
// by mocking would be overkill; just verify it doesn't exceed 100.
const r = calculateScore({ ...baseInbound, email: 'a@a.com' });
expect(r.total).toBeLessThanOrEqual(100);
});
it('hot — 71+ outbound with champion', () => {
const r = calculateScore({ const r = calculateScore({
lead_type: 'outbound', lead_type: 'outbound',
company_size: '50+', // 25 company_size: '200+', // 20
budget: 'confirmed', // 25 budget: 'confirmed', // 15
urgency: '3-6m', // 10 urgency: '1-3m', // 14
dm_contact: 'influencer', // 10 dm_contact: 'champion', // 20
prev_contact: 'well-known', // 10 prev_contact: 'well-known', // 10
email: 'cfo@bigcorp.kz', // +5
}); });
expect(r.total).toBe(80); expect(r.total).toBe(84);
expect(r.status).toBe('hot'); expect(r.status).toBe('hot');
}); });
});
it('budget=no no longer caps the score', () => { describe('classifyEmail', () => {
const r = calculateScore({ it('gmail → free', () => expect(classifyEmail('a@gmail.com')).toBe('free'));
lead_type: 'inbound', it('yandex → free', () => expect(classifyEmail('a@yandex.ru')).toBe('free'));
company_size: '50+', // 25 it('corp domain → corporate', () => expect(classifyEmail('a@acme.com')).toBe('corporate'));
budget: 'no', // 0 it('empty → empty', () => expect(classifyEmail('')).toBe('empty'));
urgency: 'under-3m', // 20 it('null → empty', () => expect(classifyEmail(null)).toBe('empty'));
dm_contact: 'decision-maker', // 20 it('no @ → empty', () => expect(classifyEmail('foo')).toBe('empty'));
source: 'website', // 10
});
expect(r.total).toBe(75);
expect(r.status).toBe('hot');
});
}); });
describe('validateScoringInput', () => { describe('validateScoringInput', () => {

View File

@ -29,3 +29,10 @@ export function updateUser(
export function deleteUser(username: string) { export function deleteUser(username: string) {
return apiFetch<void>(`/admin/users/${username}`, { method: 'DELETE' }); return apiFetch<void>(`/admin/users/${username}`, { method: 'DELETE' });
} }
export function recalcAllLeads() {
return apiFetch<{ total: number; updated: number; formula_version: number }>(
'/admin/leads/recalc',
{ method: 'POST' },
);
}

View File

@ -2,11 +2,11 @@ import { apiFetch } from './client';
export type LeadType = 'inbound' | 'outbound'; export type LeadType = 'inbound' | 'outbound';
export type LeadStatus = 'cold' | 'warm' | 'hot'; export type LeadStatus = 'cold' | 'warm' | 'hot';
export type CompanySize = '1-10' | '11-50' | '50+'; export type CompanySize = '1-10' | '11-50' | '51-200' | '200+';
export type Budget = 'no' | 'maybe' | 'confirmed'; export type Budget = 'no' | 'maybe' | 'confirmed';
export type Urgency = 'none' | '3-6m' | 'under-3m'; export type Urgency = 'none' | '6m-plus' | '3-6m' | '1-3m' | 'under-1m';
export type DmContact = 'no' | 'influencer' | 'decision-maker'; export type DmContact = 'unknown' | 'user' | 'influencer' | 'champion' | 'economic-buyer';
export type Source = 'cold' | 'referral' | 'website'; export type Source = 'cold' | 'ad' | 'content' | 'referral' | 'website' | 'demo-request';
export type PrevContact = 'none' | 'once' | 'well-known'; export type PrevContact = 'none' | 'once' | 'well-known';
export type DealStage = export type DealStage =
| 'new' | 'new'

View File

@ -2,12 +2,17 @@
interface Option { interface Option {
value: T; value: T;
label: string; label: string;
tip?: string;
points?: number;
} }
const props = defineProps<{ const props = defineProps<{
label: string; label: string;
modelValue: T | null | undefined; modelValue: T | null | undefined;
options: Option[]; options: Option[];
hint?: string;
max?: number;
earned?: number;
}>(); }>();
const emit = defineEmits<{ (e: 'update:modelValue', v: T): void }>(); const emit = defineEmits<{ (e: 'update:modelValue', v: T): void }>();
@ -19,15 +24,28 @@ function select(v: T) {
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-baseline justify-between">
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider"> <label class="block text-xs font-medium text-gray-600 uppercase tracking-wider">
{{ label }} {{ label }}
<span v-if="hint" class="ml-2 text-[10px] text-gray-400 normal-case tracking-normal">
{{ hint }}
</span>
</label> </label>
<span
v-if="max !== undefined"
class="text-[11px] font-medium text-gray-500 tabular-nums"
>
<span :class="(earned ?? 0) > 0 ? 'text-gray-900' : 'text-gray-400'">{{ earned ?? 0 }}</span
><span class="text-gray-400">/{{ max }}</span>
</span>
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
v-for="o in options" v-for="o in options"
:key="String(o.value)" :key="String(o.value)"
type="button" type="button"
class="px-3.5 py-1.5 rounded-lg border text-sm transition" :title="o.tip"
class="px-3.5 py-1.5 rounded-lg border text-sm transition inline-flex items-center gap-1.5"
:class=" :class="
props.modelValue === o.value props.modelValue === o.value
? 'bg-gray-900 text-white border-gray-900 shadow-sm' ? 'bg-gray-900 text-white border-gray-900 shadow-sm'
@ -35,7 +53,13 @@ function select(v: T) {
" "
@click="select(o.value)" @click="select(o.value)"
> >
{{ o.label }} <span>{{ o.label }}</span>
<span
v-if="o.points !== undefined"
class="text-[10px] tabular-nums opacity-70"
>
+{{ o.points }}
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,180 @@
// Mirror of backend/src/scoring.ts — keep weights in sync.
// Used by NewLeadView for live-score preview and by LeadCardView for breakdown max.
import type {
Budget,
CompanySize,
DmContact,
LeadStatus,
LeadType,
PrevContact,
Source,
Urgency,
} from '@/api/leads';
export const COMPANY_SIZE_WEIGHT: Record<CompanySize, number> = {
'1-10': 4,
'11-50': 12,
'51-200': 18,
'200+': 20,
};
export const BUDGET_WEIGHT: Record<Budget, number> = { no: 0, maybe: 8, confirmed: 15 };
export const URGENCY_WEIGHT: Record<Urgency, number> = {
none: 0,
'6m-plus': 3,
'3-6m': 8,
'1-3m': 14,
'under-1m': 20,
};
export const DM_CONTACT_WEIGHT: Record<DmContact, number> = {
unknown: 0,
user: 5,
influencer: 12,
champion: 20,
'economic-buyer': 25,
};
export const SOURCE_WEIGHT: Record<Source, number> = {
cold: 2,
ad: 4,
content: 6,
referral: 8,
website: 8,
'demo-request': 10,
};
export const PREV_CONTACT_WEIGHT: Record<PrevContact, number> = {
none: 0,
once: 5,
'well-known': 10,
};
export const SCORING_MAX: Record<string, number> = {
company_size: 20,
dm_contact: 25,
budget: 15,
urgency: 20,
source: 10,
prev_contact: 10,
email_mod: 5,
};
const FREE_MAIL_DOMAINS = new Set([
'gmail.com',
'googlemail.com',
'yahoo.com',
'yahoo.co.uk',
'hotmail.com',
'outlook.com',
'live.com',
'msn.com',
'icloud.com',
'me.com',
'mac.com',
'aol.com',
'mail.ru',
'inbox.ru',
'list.ru',
'bk.ru',
'internet.ru',
'yandex.ru',
'yandex.com',
'ya.ru',
'rambler.ru',
'protonmail.com',
'proton.me',
'gmx.com',
'gmx.net',
'zoho.com',
'tutanota.com',
'fastmail.com',
]);
export function classifyEmail(email: string): 'corporate' | 'free' | 'empty' {
if (!email) return 'empty';
const at = email.lastIndexOf('@');
if (at < 0) return 'empty';
const domain = email.slice(at + 1).trim().toLowerCase();
if (!domain || !domain.includes('.')) return 'empty';
return FREE_MAIL_DOMAINS.has(domain) ? 'free' : 'corporate';
}
export function emailMod(email: string): number {
const cls = classifyEmail(email);
if (cls === 'corporate') return 5;
if (cls === 'free') return -5;
return 0;
}
export interface LivePreviewInput {
lead_type: LeadType;
company_size: CompanySize | null;
budget: Budget | null;
urgency: Urgency | null;
dm_contact: DmContact | null;
source: Source | null;
prev_contact: PrevContact | null;
email: string;
}
export interface LivePreviewResult {
total: number;
status: LeadStatus | null;
breakdown: Record<string, number>;
filled: number;
required: number;
}
export function statusFor(total: number): LeadStatus {
if (total <= 40) return 'cold';
if (total <= 70) return 'warm';
return 'hot';
}
// Live preview — tolerant of nulls, returns partial breakdown.
export function previewScore(input: LivePreviewInput): LivePreviewResult {
const breakdown: Record<string, number> = {};
let filled = 0;
const required = input.lead_type === 'inbound' ? 5 : 5;
if (input.company_size) {
breakdown.company_size = COMPANY_SIZE_WEIGHT[input.company_size];
filled++;
}
if (input.dm_contact) {
breakdown.dm_contact = DM_CONTACT_WEIGHT[input.dm_contact];
filled++;
}
if (input.budget) {
breakdown.budget = BUDGET_WEIGHT[input.budget];
filled++;
}
if (input.urgency) {
breakdown.urgency = URGENCY_WEIGHT[input.urgency];
filled++;
}
if (input.lead_type === 'inbound' && input.source) {
breakdown.source = SOURCE_WEIGHT[input.source];
filled++;
} else if (input.lead_type === 'outbound' && input.prev_contact) {
breakdown.prev_contact = PREV_CONTACT_WEIGHT[input.prev_contact];
filled++;
}
const mod = emailMod(input.email);
if (mod !== 0) breakdown.email_mod = mod;
const raw = Object.values(breakdown).reduce((a, b) => a + b, 0);
const total = Math.max(0, Math.min(100, raw));
return {
total,
status: filled === required ? statusFor(total) : null,
breakdown,
filled,
required,
};
}

View File

@ -6,6 +6,7 @@ import {
createUser, createUser,
deleteUser, deleteUser,
listUsers, listUsers,
recalcAllLeads,
updateUser, updateUser,
type AppUser, type AppUser,
} from '@/api/admin'; } from '@/api/admin';
@ -22,6 +23,20 @@ const form = reactive<{ username: string; password: string; role: 'manager' | 'r
role: 'manager', role: 'manager',
}); });
const creating = ref(false); const creating = ref(false);
const recalcing = ref(false);
async function recalc() {
if (!confirm('Пересчитать score у всех лидов по текущей формуле?')) return;
recalcing.value = true;
try {
const r = await recalcAllLeads();
toast.success(`Пересчитано: ${r.updated} из ${r.total} (formula v${r.formula_version})`);
} catch (e) {
toast.error((e as Error).message);
} finally {
recalcing.value = false;
}
}
async function load() { async function load() {
loading.value = true; loading.value = true;
@ -138,6 +153,25 @@ onMounted(load);
</div> </div>
</form> </form>
<div class="card-pad space-y-3">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Скоринг
</h2>
<div class="flex items-center justify-between gap-3 flex-wrap">
<p class="text-sm text-gray-600">
Пересчитать score у всех лидов по текущей формуле. Лиды, где итоговый балл не изменился, не трогаются.
</p>
<button
type="button"
class="btn-secondary text-sm shrink-0"
:disabled="recalcing"
@click="recalc"
>
{{ recalcing ? 'Считаем…' : 'Пересчитать всё' }}
</button>
</div>
</div>
<div v-if="loading" class="card p-4"> <div v-if="loading" class="card p-4">
<Skeleton :rows="4" height="40px" /> <Skeleton :rows="4" height="40px" />
</div> </div>

View File

@ -65,22 +65,26 @@ const stageBadgeClass = computed(() => {
const breakdownLabels: Record<string, string> = { const breakdownLabels: Record<string, string> = {
company_size: 'Размер компании', company_size: 'Размер компании',
dm_contact: 'Кто контакт',
budget: 'Бюджет', budget: 'Бюджет',
urgency: 'Срочность', urgency: 'Срочность',
dm_contact: 'Контакт — ЛПР',
source: 'Источник', source: 'Источник',
prev_contact: 'Предыдущий контакт', prev_contact: 'Предыдущий контакт',
email_mod: 'Корп. email',
}; };
const breakdownMax: Record<string, number> = { const breakdownMax: Record<string, number> = {
company_size: 25, company_size: 20,
budget: 25, dm_contact: 25,
budget: 15,
urgency: 20, urgency: 20,
dm_contact: 20,
source: 10, source: 10,
prev_contact: 10, prev_contact: 10,
email_mod: 5,
}; };
const SCORE_MAX = 95;
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
@ -330,7 +334,7 @@ onMounted(load);
</ul> </ul>
<div class="flex justify-between pt-2 border-t border-gray-200 font-semibold"> <div class="flex justify-between pt-2 border-t border-gray-200 font-semibold">
<span>Итого</span> <span>Итого</span>
<span>{{ lead.score }} / 100</span> <span>{{ lead.score }} / {{ SCORE_MAX }}</span>
</div> </div>
</div> </div>

View File

@ -18,6 +18,16 @@ import {
type Urgency, type Urgency,
} from '@/api/leads'; } from '@/api/leads';
import { ApiRequestError } from '@/api/client'; import { ApiRequestError } from '@/api/client';
import {
BUDGET_WEIGHT,
COMPANY_SIZE_WEIGHT,
DM_CONTACT_WEIGHT,
PREV_CONTACT_WEIGHT,
SOURCE_WEIGHT,
URGENCY_WEIGHT,
classifyEmail,
previewScore,
} from '@/scoring';
interface FormState { interface FormState {
contact_name: string; contact_name: string;
@ -69,44 +79,82 @@ const loading = ref(false);
const router = useRouter(); const router = useRouter();
const leadTypeOpts: { value: LeadType; label: string }[] = [ const leadTypeOpts: { value: LeadType; label: string }[] = [
{ value: 'inbound', label: 'Inbound' }, { value: 'inbound', label: 'Входящий' },
{ value: 'outbound', label: 'Outbound' }, { value: 'outbound', label: 'Исходящий' },
]; ];
const companySizeOpts: { value: CompanySize; label: string }[] = [ const companySizeOpts: { value: CompanySize; label: string; points: number }[] = [
{ value: '1-10', label: '1–10' }, { value: '1-10', label: '1–10', points: COMPANY_SIZE_WEIGHT['1-10'] },
{ value: '11-50', label: '11–50' }, { value: '11-50', label: '11–50', points: COMPANY_SIZE_WEIGHT['11-50'] },
{ value: '50+', label: '50+' }, { value: '51-200', label: '51–200', points: COMPANY_SIZE_WEIGHT['51-200'] },
{ value: '200+', label: '200+', points: COMPANY_SIZE_WEIGHT['200+'] },
]; ];
const budgetOpts: { value: Budget; label: string }[] = [ const budgetOpts: { value: Budget; label: string; points: number }[] = [
{ value: 'no', label: 'Нет' }, { value: 'no', label: 'Нет', points: BUDGET_WEIGHT.no },
{ value: 'maybe', label: 'Возможно' }, { value: 'maybe', label: 'Возможно', points: BUDGET_WEIGHT.maybe },
{ value: 'confirmed', label: 'Подтверждён' }, { value: 'confirmed', label: 'Подтверждён', points: BUDGET_WEIGHT.confirmed },
]; ];
const urgencyOpts: { value: Urgency; label: string }[] = [ const urgencyOpts: { value: Urgency; label: string; points: number }[] = [
{ value: 'none', label: 'Нет дедлайна' }, { value: 'none', label: 'Нет', points: URGENCY_WEIGHT.none },
{ value: '3-6m', label: '3–6 месяцев' }, { value: '6m-plus', label: 'Больше 6 мес', points: URGENCY_WEIGHT['6m-plus'] },
{ value: 'under-3m', label: 'До 3 месяцев' }, { value: '3-6m', label: '3–6 мес', points: URGENCY_WEIGHT['3-6m'] },
{ value: '1-3m', label: '1–3 мес', points: URGENCY_WEIGHT['1-3m'] },
{ value: 'under-1m', label: 'Меньше месяца', points: URGENCY_WEIGHT['under-1m'] },
]; ];
const dmContactOpts: { value: DmContact; label: string }[] = [ const dmContactOpts: {
{ value: 'no', label: 'Нет' }, value: DmContact;
{ value: 'influencer', label: 'Влияет на решение' }, label: string;
{ value: 'decision-maker', label: 'Принимает решение' }, points: number;
tip: string;
}[] = [
{
value: 'unknown',
label: 'Не выяснено',
points: DM_CONTACT_WEIGHT.unknown,
tip: 'Кто решает — пока не знаем',
},
{
value: 'user',
label: 'Пользователь',
points: DM_CONTACT_WEIGHT.user,
tip: 'Будет пользоваться продуктом, но решение не принимает',
},
{
value: 'influencer',
label: 'Влияет на выбор',
points: DM_CONTACT_WEIGHT.influencer,
tip: 'Не подписывает, но мнение слушают',
},
{
value: 'champion',
label: 'Внутренний адвокат',
points: DM_CONTACT_WEIGHT.champion,
tip: 'Продаёт за тебя внутри компании, проводит к ЛПР',
},
{
value: 'economic-buyer',
label: 'Распорядитель бюджета',
points: DM_CONTACT_WEIGHT['economic-buyer'],
tip: 'Подписывает счёт, окончательное «да» за ним',
},
]; ];
const sourceOpts: { value: Source; label: string }[] = [ const sourceOpts: { value: Source; label: string; points: number; tip?: string }[] = [
{ value: 'cold', label: 'Холодный' }, { value: 'cold', label: 'Холодный список', points: SOURCE_WEIGHT.cold },
{ value: 'referral', label: 'Реферал' }, { value: 'ad', label: 'Реклама', points: SOURCE_WEIGHT.ad },
{ value: 'website', label: 'Сайт / входящий' }, { value: 'content', label: 'Скачал материал', points: SOURCE_WEIGHT.content, tip: 'White-paper, кейс, гайд' },
{ value: 'referral', label: 'Рекомендация', points: SOURCE_WEIGHT.referral },
{ value: 'website', label: 'Сайт / форма', points: SOURCE_WEIGHT.website },
{ value: 'demo-request', label: 'Запросил демо', points: SOURCE_WEIGHT['demo-request'] },
]; ];
const prevContactOpts: { value: PrevContact; label: string }[] = [ const prevContactOpts: { value: PrevContact; label: string; points: number }[] = [
{ value: 'none', label: 'Не было' }, { value: 'none', label: 'Не было', points: PREV_CONTACT_WEIGHT.none },
{ value: 'once', label: 'Встречались один раз' }, { value: 'once', label: 'Встречались один раз', points: PREV_CONTACT_WEIGHT.once },
{ value: 'well-known', label: 'Знакомы хорошо' }, { value: 'well-known', label: 'Знакомы хорошо', points: PREV_CONTACT_WEIGHT['well-known'] },
]; ];
const PHONE_RE = /^\+?\d[\d\s\-()]{8,}$/; const PHONE_RE = /^\+?\d[\d\s\-()]{8,}$/;
@ -134,9 +182,7 @@ function validateFields(): Record<string, string> {
} }
const liveErrors = computed(() => validateFields()); const liveErrors = computed(() => validateFields());
const canSubmit = computed(() => Object.keys(liveErrors.value).length === 0); const canSubmit = computed(() => Object.keys(liveErrors.value).length === 0);
const attempted = ref(false); const attempted = ref(false);
const displayErrors = computed<Record<string, string>>(() => { const displayErrors = computed<Record<string, string>>(() => {
@ -144,6 +190,44 @@ const displayErrors = computed<Record<string, string>>(() => {
return { ...liveErrors.value, ...fieldErrors.value }; return { ...liveErrors.value, ...fieldErrors.value };
}); });
const preview = computed(() =>
previewScore({
lead_type: form.lead_type,
company_size: form.company_size,
budget: form.budget,
urgency: form.urgency,
dm_contact: form.dm_contact,
source: form.source,
prev_contact: form.prev_contact,
email: form.email,
}),
);
const statusColor = computed(() => {
const s = preview.value.status;
if (s === 'hot') return 'bg-emerald-500';
if (s === 'warm') return 'bg-amber-500';
if (s === 'cold') return 'bg-sky-500';
return 'bg-gray-300';
});
const statusLabel = computed(() => {
const s = preview.value.status;
if (s === 'hot') return 'HOT';
if (s === 'warm') return 'WARM';
if (s === 'cold') return 'COLD';
return '—';
});
const emailClass = computed(() => classifyEmail(form.email));
const emailMod = computed(() => {
if (emailClass.value === 'corporate') return 5;
if (emailClass.value === 'free') return -5;
return 0;
});
const breakdown = computed(() => preview.value.breakdown);
async function submit() { async function submit() {
error.value = null; error.value = null;
attempted.value = true; attempted.value = true;
@ -200,7 +284,7 @@ async function submit() {
</script> </script>
<template> <template>
<div class="max-w-2xl mx-auto space-y-6"> <div class="max-w-3xl mx-auto space-y-6">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Новый лид</h1> <h1 class="text-2xl font-semibold tracking-tight">Новый лид</h1>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
@ -208,6 +292,36 @@ async function submit() {
</p> </p>
</div> </div>
<!-- Sticky live score panel -->
<div
class="sticky top-2 z-10 rounded-2xl border bg-white shadow-sm px-4 py-3 flex items-center gap-4"
>
<div
class="w-14 h-14 rounded-full text-white text-xl font-bold flex items-center justify-center tabular-nums shrink-0 transition-colors"
:class="statusColor"
>
{{ preview.total }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline justify-between gap-2">
<span class="text-sm font-semibold text-gray-900">
{{ statusLabel }}
<span class="text-gray-400 font-normal">· {{ preview.filled }}/{{ preview.required }} вопросов</span>
</span>
<span class="text-xs text-gray-500 tabular-nums">
cold ≤40 · warm ≤70 · hot ≥71
</span>
</div>
<div class="mt-2 h-2 rounded-full bg-gray-100 overflow-hidden">
<div
class="h-full transition-all duration-200"
:class="statusColor"
:style="{ width: preview.total + '%' }"
/>
</div>
</div>
</div>
<form class="card-pad space-y-8" @submit.prevent="submit"> <form class="card-pad space-y-8" @submit.prevent="submit">
<section class="space-y-4"> <section class="space-y-4">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Контакт</h2> <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Контакт</h2>
@ -242,8 +356,15 @@ async function submit() {
</p> </p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1"> <label class="block text-sm font-medium mb-1 flex items-center justify-between">
Email <span class="text-red-600">*</span> <span>Email <span class="text-red-600">*</span></span>
<span
v-if="form.email && emailMod !== 0"
class="text-xs font-medium tabular-nums"
:class="emailMod > 0 ? 'text-emerald-600' : 'text-red-600'"
>
{{ emailMod > 0 ? '+5 корп' : '−5 free-mail' }}
</span>
</label> </label>
<input <input
v-model="form.email" v-model="form.email"
@ -327,8 +448,8 @@ async function submit() {
</div> </div>
</section> </section>
<section class="space-y-4"> <section class="space-y-5">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3"> <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Скоринг <span class="text-red-600">*</span> Скоринг <span class="text-red-600">*</span>
</h2> </h2>
<QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" /> <QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" />
@ -337,38 +458,57 @@ async function submit() {
label="Размер компании" label="Размер компании"
v-model="form.company_size" v-model="form.company_size"
:options="companySizeOpts" :options="companySizeOpts"
:max="20"
:earned="breakdown.company_size ?? 0"
/> />
<p v-if="displayErrors.company_size" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.company_size" class="mt-1 text-xs text-red-600">
{{ displayErrors.company_size }} {{ displayErrors.company_size }}
</p> </p>
</div> </div>
<div> <div>
<QuestionButton label="Бюджет" v-model="form.budget" :options="budgetOpts" /> <QuestionButton
label="Кто наш контакт?"
hint="MEDDIC — наведи на вариант для пояснения"
v-model="form.dm_contact"
:options="dmContactOpts"
:max="25"
:earned="breakdown.dm_contact ?? 0"
/>
<p v-if="displayErrors.dm_contact" class="mt-1 text-xs text-red-600">
{{ displayErrors.dm_contact }}
</p>
</div>
<div>
<QuestionButton
label="Бюджет"
v-model="form.budget"
:options="budgetOpts"
:max="15"
:earned="breakdown.budget ?? 0"
/>
<p v-if="displayErrors.budget" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.budget" class="mt-1 text-xs text-red-600">
{{ displayErrors.budget }} {{ displayErrors.budget }}
</p> </p>
</div> </div>
<div> <div>
<QuestionButton label="Срочность" v-model="form.urgency" :options="urgencyOpts" /> <QuestionButton
label="Срочность"
v-model="form.urgency"
:options="urgencyOpts"
:max="20"
:earned="breakdown.urgency ?? 0"
/>
<p v-if="displayErrors.urgency" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.urgency" class="mt-1 text-xs text-red-600">
{{ displayErrors.urgency }} {{ displayErrors.urgency }}
</p> </p>
</div> </div>
<div>
<QuestionButton
label="Контакт — ЛПР?"
v-model="form.dm_contact"
:options="dmContactOpts"
/>
<p v-if="displayErrors.dm_contact" class="mt-1 text-xs text-red-600">
{{ displayErrors.dm_contact }}
</p>
</div>
<div v-if="form.lead_type === 'inbound'"> <div v-if="form.lead_type === 'inbound'">
<QuestionButton <QuestionButton
label="Источник лида" label="Источник лида"
v-model="form.source" v-model="form.source"
:options="sourceOpts" :options="sourceOpts"
:max="10"
:earned="breakdown.source ?? 0"
/> />
<p v-if="displayErrors.source" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.source" class="mt-1 text-xs text-red-600">
{{ displayErrors.source }} {{ displayErrors.source }}
@ -379,6 +519,8 @@ async function submit() {
label="Предыдущий контакт" label="Предыдущий контакт"
v-model="form.prev_contact" v-model="form.prev_contact"
:options="prevContactOpts" :options="prevContactOpts"
:max="10"
:earned="breakdown.prev_contact ?? 0"
/> />
<p v-if="displayErrors.prev_contact" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.prev_contact" class="mt-1 text-xs text-red-600">
{{ displayErrors.prev_contact }} {{ displayErrors.prev_contact }}

View File

@ -0,0 +1,55 @@
-- Up Migration
-- 1) Drop old CHECK constraints
ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_company_size_check;
ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_dm_contact_check;
ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_source_check;
ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_urgency_check;
-- 2) Migrate existing values to new enums
UPDATE leads SET company_size = '51-200' WHERE company_size = '50+';
UPDATE leads SET dm_contact = 'unknown' WHERE dm_contact = 'no';
UPDATE leads SET dm_contact = 'economic-buyer' WHERE dm_contact = 'decision-maker';
UPDATE leads SET urgency = '1-3m' WHERE urgency = 'under-3m';
-- 3) New CHECK constraints
ALTER TABLE leads ADD CONSTRAINT leads_company_size_check
CHECK (company_size IN ('1-10', '11-50', '51-200', '200+'));
ALTER TABLE leads ADD CONSTRAINT leads_dm_contact_check
CHECK (dm_contact IN ('unknown', 'user', 'influencer', 'champion', 'economic-buyer'));
ALTER TABLE leads ADD CONSTRAINT leads_source_check
CHECK (source IN ('cold', 'ad', 'content', 'referral', 'website', 'demo-request'));
ALTER TABLE leads ADD CONSTRAINT leads_urgency_check
CHECK (urgency IN ('none', '6m-plus', '3-6m', '1-3m', 'under-1m'));
-- 4) Track scoring formula version on history rows
ALTER TABLE score_history ADD COLUMN formula_version INT NOT NULL DEFAULT 1;
ALTER TABLE score_history ALTER COLUMN formula_version SET DEFAULT 2;
-- Down Migration
-- ALTER TABLE score_history DROP COLUMN formula_version;
--
-- ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_company_size_check;
-- ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_dm_contact_check;
-- ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_source_check;
-- ALTER TABLE leads DROP CONSTRAINT IF EXISTS leads_urgency_check;
--
-- UPDATE leads SET company_size = '50+' WHERE company_size IN ('51-200', '200+');
-- UPDATE leads SET dm_contact = 'no' WHERE dm_contact IN ('unknown', 'user');
-- UPDATE leads SET dm_contact = 'decision-maker' WHERE dm_contact IN ('champion', 'economic-buyer');
-- UPDATE leads SET urgency = 'under-3m' WHERE urgency IN ('1-3m', 'under-1m');
-- UPDATE leads SET source = 'website' WHERE source IN ('ad', 'content', 'demo-request');
--
-- ALTER TABLE leads ADD CONSTRAINT leads_company_size_check
-- CHECK (company_size IN ('1-10', '11-50', '50+'));
-- ALTER TABLE leads ADD CONSTRAINT leads_dm_contact_check
-- CHECK (dm_contact IN ('no', 'influencer', 'decision-maker'));
-- ALTER TABLE leads ADD CONSTRAINT leads_source_check
-- CHECK (source IN ('cold', 'referral', 'website'));
-- ALTER TABLE leads ADD CONSTRAINT leads_urgency_check
-- CHECK (urgency IN ('none', '3-6m', 'under-3m'));