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:
parent
ef92acdd3f
commit
171fd4636c
83
PRD.md
83
PRD.md
@ -17,46 +17,64 @@
|
||||
|
||||
| Критерий | Inbound | Outbound | Макс. баллов |
|
||||
|---|---|---|---|
|
||||
| Размер компании | ✅ | ✅ | 25 |
|
||||
| Подтверждён бюджет | ✅ | ✅ | 25 |
|
||||
| Размер компании | ✅ | ✅ | 20 |
|
||||
| Кто наш контакт (MEDDIC) | ✅ | ✅ | 25 |
|
||||
| Бюджет | ✅ | ✅ | 15 |
|
||||
| Срочность / дедлайн | ✅ | ✅ | 20 |
|
||||
| Контакт — ЛПР? | ✅ | ✅ | 15 |
|
||||
| Источник лида | ✅ | — | 10 |
|
||||
| Предыдущий контакт | — | ✅ | 10 |
|
||||
| Соответствие ICP | ✅ | ✅ | 5 |
|
||||
| **Итого** | | | **100** |
|
||||
| **Подсумма (вопросы формы)** | | | **90** |
|
||||
| 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 корректно.
|
||||
|
||||
### Детальная логика баллов
|
||||
|
||||
**Размер компании**
|
||||
- 1–10 сотрудников → 5 баллов
|
||||
- 11–50 сотрудников → 15 баллов
|
||||
- 50+ сотрудников → 25 баллов
|
||||
**Размер компании** (0..20)
|
||||
- 1–10 сотрудников → 4
|
||||
- 11–50 → 12
|
||||
- 51–200 → 18
|
||||
- 200+ → 20
|
||||
|
||||
**Бюджет**
|
||||
- Нет → 0 баллов
|
||||
- Возможно → 12 баллов
|
||||
- Подтверждён → 25 баллов
|
||||
**Кто наш контакт?** (0..25, MEDDIC-лестница)
|
||||
- Не выяснено → 0
|
||||
- Конечный пользователь (будет работать с продуктом, но не решает) → 5
|
||||
- Влияет на выбор (слушают его мнение) → 12
|
||||
- Внутренний адвокат (champion — продаёт за нас внутри) → 20
|
||||
- Распорядитель бюджета (economic buyer — подпись на счёте) → 25
|
||||
|
||||
**Срочность**
|
||||
- Нет дедлайна → 0 баллов
|
||||
- 3–6 месяцев → 10 баллов
|
||||
- До 3 месяцев → 20 баллов
|
||||
**Бюджет** (0..15)
|
||||
- Нет → 0
|
||||
- Возможно → 8
|
||||
- Подтверждён → 15
|
||||
|
||||
**Контакт — ЛПР?**
|
||||
- Нет → 0 баллов
|
||||
- Влияет на решение → 8 баллов
|
||||
- Принимает решение → 15 баллов
|
||||
**Срочность** (0..20)
|
||||
- Нет дедлайна → 0
|
||||
- Больше 6 месяцев → 3
|
||||
- 3–6 месяцев → 8
|
||||
- 1–3 месяца → 14
|
||||
- Меньше месяца → 20
|
||||
|
||||
**Источник лида (только inbound)**
|
||||
- Холодный → 3 балла
|
||||
- Реферал → 7 баллов
|
||||
- Сайт / входящий → 10 баллов
|
||||
**Источник лида** (только inbound, 0..10)
|
||||
- Холодный список → 2
|
||||
- Реклама → 4
|
||||
- Скачал материал → 6
|
||||
- Рекомендация → 8
|
||||
- Сайт / форма → 8
|
||||
- Запросил демо → 10
|
||||
|
||||
**Предыдущий контакт (только outbound)**
|
||||
- Не было → 0 баллов
|
||||
- Встречались один раз → 5 баллов
|
||||
- Знакомы хорошо → 10 баллов
|
||||
**Предыдущий контакт** (только outbound, 0..10)
|
||||
- Не было → 0
|
||||
- Встречались один раз → 5
|
||||
- Знакомы хорошо → 10
|
||||
|
||||
**Email-модификатор** (тихий, считается из домена)
|
||||
- Корпоративный домен → +5
|
||||
- Free-mail (gmail, yandex, mail.ru, hotmail, outlook, icloud, proton, …) → −5
|
||||
- Email пустой → 0
|
||||
|
||||
---
|
||||
|
||||
@ -66,7 +84,7 @@
|
||||
|---|---|---|
|
||||
| 🔴 0–40 | Холодный | Не трать время сейчас |
|
||||
| 🟡 41–70 | Тёплый | Nurture, следи за активностью |
|
||||
| 🟢 71–100 | Горячий | Звони сегодня |
|
||||
| 🟢 71–95 | Горячий | Звони сегодня |
|
||||
|
||||
---
|
||||
|
||||
@ -84,7 +102,10 @@
|
||||
- Имя контакта
|
||||
- Название компании
|
||||
- Тип лида: Inbound / Outbound (переключатель)
|
||||
- 6 вопросов скоринга — **кнопки выбора, не текстовые поля** (быстро заполнять)
|
||||
- 5 вопросов скоринга — **кнопки выбора, не текстовые поля** (быстро заполнять)
|
||||
- Над формой висит **живая полоса score**: меняется на каждый клик, цвет cold/warm/hot
|
||||
- У каждой кнопки выбора видны баллы (`+12`, `+25`); у заголовка вопроса — `earned/max`
|
||||
- Email-модификатор показывается значком рядом с полем email (`+5 корп` / `−5 free-mail`)
|
||||
- Автоматический расчёт score при отправке
|
||||
|
||||
### 3. Карточка лида
|
||||
|
||||
106
TRD.md
106
TRD.md
@ -43,20 +43,21 @@ Traefik ──► nginx (:80) ──► / ──► frontend :3000 (
|
||||
### Таблица `leads`
|
||||
|
||||
```sql
|
||||
-- Историческая справка: до миграции 1700000004000_drop_icp_fit столбец `icp_fit BOOLEAN NOT NULL`
|
||||
-- также участвовал в скоринге (+5 баллов при true). Убран — плохо квантовался при вводе.
|
||||
CREATE TABLE leads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contact_name TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
lead_type TEXT NOT NULL CHECK (lead_type IN ('inbound', 'outbound')),
|
||||
|
||||
-- ответы на вопросы скоринга
|
||||
company_size TEXT NOT NULL CHECK (company_size IN ('1-10', '11-50', '50+')),
|
||||
-- ответы на вопросы скоринга (формула v2, см. миграцию 1700000007000_scoring_v2.sql)
|
||||
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')),
|
||||
urgency TEXT NOT NULL CHECK (urgency IN ('none', '3-6m', 'under-3m')),
|
||||
dm_contact TEXT NOT NULL CHECK (dm_contact IN ('no', 'influencer', 'decision-maker')),
|
||||
source TEXT CHECK (source IN ('cold', 'referral', 'website')), -- только inbound
|
||||
prev_contact TEXT CHECK (prev_contact IN ('none', 'once', 'well-known')), -- только outbound
|
||||
icp_fit BOOLEAN NOT NULL,
|
||||
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 ('unknown', 'user', 'influencer', 'champion', 'economic-buyer')),
|
||||
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
|
||||
|
||||
-- computed
|
||||
score INT NOT NULL CHECK (score BETWEEN 0 AND 100),
|
||||
@ -82,18 +83,24 @@ CREATE INDEX leads_lead_type ON leads (lead_type);
|
||||
|
||||
```sql
|
||||
CREATE TABLE score_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
total INT NOT NULL,
|
||||
breakdown JSONB NOT NULL, -- {company_size: 15, budget: 25, ...}
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
changed_by TEXT NOT NULL
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
total INT NOT NULL,
|
||||
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_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
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
|
||||
type ScoringInput = {
|
||||
lead_type: 'inbound' | 'outbound';
|
||||
company_size: '1-10' | '11-50' | '50+';
|
||||
company_size: '1-10' | '11-50' | '51-200' | '200+';
|
||||
budget: 'no' | 'maybe' | 'confirmed';
|
||||
urgency: 'none' | '3-6m' | 'under-3m';
|
||||
dm_contact: 'no' | 'influencer' | 'decision-maker';
|
||||
source?: 'cold' | 'referral' | 'website'; // только inbound
|
||||
prev_contact?: 'none' | 'once' | 'well-known'; // только outbound
|
||||
icp_fit: boolean;
|
||||
urgency: 'none' | '6m-plus' | '3-6m' | '1-3m' | 'under-1m';
|
||||
dm_contact: 'unknown' | 'user' | 'influencer' | 'champion' | 'economic-buyer';
|
||||
source?: 'cold' | 'ad' | 'content' | 'referral' | 'website' | 'demo-request'; // только inbound
|
||||
prev_contact?: 'none' | 'once' | 'well-known'; // только outbound
|
||||
email?: string | null; // используется для email-модификатора (±5)
|
||||
};
|
||||
|
||||
type ScoringResult = {
|
||||
total: number; // 0..100
|
||||
breakdown: Record<string, number>; // каждый критерий → баллы
|
||||
total: number; // clamp [0..100]; теоретический максимум формулы — 95
|
||||
breakdown: Record<string, number>;
|
||||
status: 'cold' | 'warm' | 'hot';
|
||||
};
|
||||
|
||||
export const SCORING_FORMULA_VERSION = 2;
|
||||
```
|
||||
|
||||
### Таблицы баллов (из PRD §«Детальная логика»)
|
||||
|
||||
| Критерий | Значения → баллы |
|
||||
|---|---|
|
||||
| `company_size` | `1-10`→5, `11-50`→15, `50+`→25 |
|
||||
| `budget` | `no`→0, `maybe`→12, `confirmed`→25 |
|
||||
| `urgency` | `none`→0, `3-6m`→10, `under-3m`→20 |
|
||||
| `dm_contact` | `no`→0, `influencer`→8, `decision-maker`→15 |
|
||||
| `source` (inbound) | `cold`→3, `referral`→7, `website`→10 |
|
||||
| `prev_contact` (outbound) | `none`→0, `once`→5, `well-known`→10 |
|
||||
| `icp_fit` | `true`→5, `false`→0 |
|
||||
| Критерий | Max | Значения → баллы |
|
||||
|---|---|---|
|
||||
| `company_size` | 20 | `1-10`→4, `11-50`→12, `51-200`→18, `200+`→20 |
|
||||
| `dm_contact` | 25 | `unknown`→0, `user`→5, `influencer`→12, `champion`→20, `economic-buyer`→25 |
|
||||
| `budget` | 15 | `no`→0, `maybe`→8, `confirmed`→15 |
|
||||
| `urgency` | 20 | `none`→0, `6m-plus`→3, `3-6m`→8, `1-3m`→14, `under-1m`→20 |
|
||||
| `source` (inbound) | 10 | `cold`→2, `ad`→4, `content`→6, `referral`→8, `website`→8, `demo-request`→10 |
|
||||
| `prev_contact` (outbound) | 10 | `none`→0, `once`→5, `well-known`→10 |
|
||||
| `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}`. |
|
||||
| POST | `/api/leads` | любой | Создать. Body — форма PRD §2 + 6 ответов. Score считается на сервере, игнорируется если пришёл в теле. Пишет запись в `score_history`. |
|
||||
| GET | `/api/leads/:id` | любой | Полная карточка (lead + последний breakdown). |
|
||||
| PATCH | `/api/leads/:id` | любой | Редактирование. Пересчитывает score. Если score меняется → запись в `score_history`. |
|
||||
| GET | `/api/leads/:id/history` | любой | История изменений score. |
|
||||
| GET | `/api/leads` | любой | Список. Query: `status`, `lead_type`, `sort` (default `score_desc`), `limit`, `offset`. Менеджер видит только свои; РОП — все. Возвращает `{data: Lead[], total}`. |
|
||||
| POST | `/api/leads` | любой | Создать. Body — форма PRD §2 + 5 ответов скоринга. Score (с учётом email-модификатора) считается на сервере, игнорируется если пришёл в теле. Пишет запись в `score_history`. |
|
||||
| GET | `/api/leads/:id` | свой или `rop` | Полная карточка (lead + последний breakdown). Менеджер получает 403, если лид не его. |
|
||||
| PATCH | `/api/leads/:id` | свой или `rop` | Редактирование. Пересчитывает score. Если score меняется → запись в `score_history`. |
|
||||
| GET | `/api/leads/:id/history` | свой или `rop` | История изменений score. |
|
||||
| DELETE | `/api/leads/:id` | только `rop` | Удалить. |
|
||||
|
||||
Роль `rop` отличается только правом на DELETE (задел; если не нужно — скажи, выкинем).
|
||||
| POST | `/api/leads/:id/reassign` | только `rop` | Body `{assigned_to}`. В одной транзакции переназначает владельца лида и все его открытые задачи. |
|
||||
| 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": "ООО Ромашка",
|
||||
"lead_type": "inbound",
|
||||
"answers": {
|
||||
"company_size": "50+", "budget": "confirmed", "urgency": "under-3m",
|
||||
"dm_contact": "decision-maker", "source": "website", "icp_fit": true
|
||||
"company_size": "200+", "budget": "confirmed", "urgency": "under-1m",
|
||||
"dm_contact": "economic-buyer", "source": "demo-request"
|
||||
},
|
||||
"score": 100,
|
||||
"score": 95,
|
||||
"status": "hot",
|
||||
"breakdown": {
|
||||
"company_size": 25, "budget": 25, "urgency": 20,
|
||||
"dm_contact": 15, "source": 10, "icp_fit": 5
|
||||
"company_size": 20, "dm_contact": 25, "budget": 15,
|
||||
"urgency": 20, "source": 10, "email_mod": 5
|
||||
},
|
||||
"created_at": "2026-04-24T10:00:00Z",
|
||||
"updated_at": "2026-04-24T10:00:00Z",
|
||||
@ -270,6 +285,11 @@ frontend/
|
||||
- В форме — **только кнопки выбора** (radio-группы, никаких текстовых полей для ответов). Имя и компания — текст.
|
||||
- Поля формы, зависящие от `lead_type`, показываются/скрываются реактивно: inbound → поле «Источник», outbound → «Предыдущий контакт».
|
||||
- На карточке лида — 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
|
||||
|
||||
|
||||
@ -10,6 +10,9 @@ import {
|
||||
} from '../auth.js';
|
||||
import { audit } from '../audit.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' {
|
||||
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');
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@ -105,17 +105,25 @@ export async function intakeRoutes(app: FastifyInstance) {
|
||||
const lead_type = pickEnum<LeadType>(body.lead_type, ['inbound', 'outbound'], 'inbound');
|
||||
const company_size = pickEnum<CompanySize>(
|
||||
body.company_size,
|
||||
['1-10', '11-50', '50+'],
|
||||
['1-10', '11-50', '51-200', '200+'],
|
||||
'1-10',
|
||||
);
|
||||
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>(
|
||||
body.dm_contact,
|
||||
['no', 'influencer', 'decision-maker'],
|
||||
'no',
|
||||
['unknown', 'user', 'influencer', 'champion', 'economic-buyer'],
|
||||
'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>(
|
||||
body.prev_contact,
|
||||
['none', 'once', 'well-known'],
|
||||
@ -130,6 +138,7 @@ export async function intakeRoutes(app: FastifyInstance) {
|
||||
dm_contact,
|
||||
source: lead_type === 'inbound' ? source : undefined,
|
||||
prev_contact: lead_type === 'outbound' ? prev_contact : undefined,
|
||||
email: emailRaw || null,
|
||||
};
|
||||
const { total, status } = calculateScore(scoring);
|
||||
|
||||
|
||||
@ -80,7 +80,7 @@ function parseDateOnly(v: unknown): string | null {
|
||||
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;
|
||||
if (lead_type !== 'inbound' && lead_type !== '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'],
|
||||
urgency: body.urgency as ScoringInput['urgency'],
|
||||
dm_contact: body.dm_contact as ScoringInput['dm_contact'],
|
||||
email,
|
||||
};
|
||||
if (lead_type === 'inbound') {
|
||||
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 ?? {};
|
||||
}
|
||||
|
||||
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) {
|
||||
app.addHook('onRequest', async (req) => {
|
||||
await requireAuth(app, req);
|
||||
@ -377,7 +390,7 @@ export async function leadRoutes(app: FastifyInstance) {
|
||||
const expected_amount = parseAmount(body.expected_amount);
|
||||
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 { rows } = await query<LeadRow>(
|
||||
@ -424,22 +437,16 @@ export async function leadRoutes(app: FastifyInstance) {
|
||||
|
||||
// GET BY ID
|
||||
app.get<{ Params: { id: string } }>('/leads/:id', async (req) => {
|
||||
const { rows } = await query<LeadRow>(`SELECT * FROM leads WHERE id = $1`, [req.params.id]);
|
||||
const lead = rows[0];
|
||||
if (!lead) throw AppError.notFound('Lead not found');
|
||||
const user = await requireAuth(app, req);
|
||||
const lead = await checkLeadAccess(req.params.id, user);
|
||||
const breakdown = await latestBreakdown(lead.id);
|
||||
return serializeLead(lead, breakdown);
|
||||
});
|
||||
|
||||
// PATCH
|
||||
app.patch<{ Params: { id: string }; Body: LeadBody }>('/leads/:id', async (req) => {
|
||||
const user = req.user as { sub: string };
|
||||
const { rows: currentRows } = await query<LeadRow>(
|
||||
`SELECT * FROM leads WHERE id = $1`,
|
||||
[req.params.id],
|
||||
);
|
||||
const current = currentRows[0];
|
||||
if (!current) throw AppError.notFound('Lead not found');
|
||||
const user = await requireAuth(app, req);
|
||||
const current = await checkLeadAccess(req.params.id, user);
|
||||
|
||||
const body = req.body ?? {};
|
||||
|
||||
@ -481,6 +488,7 @@ export async function leadRoutes(app: FastifyInstance) {
|
||||
budget: (body.budget ?? current.budget) as ScoringInput['budget'],
|
||||
urgency: (body.urgency ?? current.urgency) as ScoringInput['urgency'],
|
||||
dm_contact: (body.dm_contact ?? current.dm_contact) as ScoringInput['dm_contact'],
|
||||
email: email || null,
|
||||
};
|
||||
if (lead_type === 'inbound') {
|
||||
const src = body.source ?? current.source;
|
||||
@ -542,8 +550,8 @@ export async function leadRoutes(app: FastifyInstance) {
|
||||
|
||||
// HISTORY
|
||||
app.get<{ Params: { id: string } }>('/leads/:id/history', async (req) => {
|
||||
const { rowCount } = await query(`SELECT 1 FROM leads WHERE id = $1`, [req.params.id]);
|
||||
if (!rowCount) throw AppError.notFound('Lead not found');
|
||||
const user = await requireAuth(app, req);
|
||||
await checkLeadAccess(req.params.id, user);
|
||||
|
||||
const { rows } = await query<{
|
||||
id: string;
|
||||
@ -586,11 +594,20 @@ export async function leadRoutes(app: FastifyInstance) {
|
||||
const { rows } = await query<LeadRow & { prev: string }>(
|
||||
`WITH cur AS (
|
||||
SELECT created_by AS prev FROM leads WHERE id = $1
|
||||
),
|
||||
upd_lead AS (
|
||||
UPDATE leads
|
||||
SET created_by = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
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
|
||||
)
|
||||
UPDATE leads
|
||||
SET created_by = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *, (SELECT prev FROM cur) AS prev`,
|
||||
SELECT * FROM upd_lead`,
|
||||
[req.params.id, target],
|
||||
);
|
||||
if (rows.length === 0) throw AppError.notFound('Lead not found');
|
||||
|
||||
@ -11,12 +11,110 @@ import type {
|
||||
} from './types.js';
|
||||
import { AppError } from './errors.js';
|
||||
|
||||
const COMPANY_SIZE: Record<CompanySize, number> = { '1-10': 5, '11-50': 15, '50+': 25 };
|
||||
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 DM_CONTACT: Record<DmContact, number> = { no: 0, influencer: 10, 'decision-maker': 20 };
|
||||
const SOURCE: Record<Source, number> = { cold: 3, referral: 7, website: 10 };
|
||||
const PREV_CONTACT: Record<PrevContact, number> = { none: 0, once: 5, 'well-known': 10 };
|
||||
export const SCORING_FORMULA_VERSION = 2;
|
||||
|
||||
const COMPANY_SIZE: Record<CompanySize, number> = {
|
||||
'1-10': 4,
|
||||
'11-50': 12,
|
||||
'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 {
|
||||
const fields: Record<string, string> = {};
|
||||
@ -54,9 +152,9 @@ export function calculateScore(input: ScoringInput): ScoringResult {
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
company_size: COMPANY_SIZE[input.company_size],
|
||||
dm_contact: DM_CONTACT[input.dm_contact],
|
||||
budget: BUDGET[input.budget],
|
||||
urgency: URGENCY[input.urgency],
|
||||
dm_contact: DM_CONTACT[input.dm_contact],
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
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 Urgency = 'none' | '3-6m' | 'under-3m';
|
||||
export type DmContact = 'no' | 'influencer' | 'decision-maker';
|
||||
export type Source = 'cold' | 'referral' | 'website';
|
||||
export type Urgency = 'none' | '6m-plus' | '3-6m' | '1-3m' | 'under-1m';
|
||||
export type DmContact = 'unknown' | 'user' | 'influencer' | 'champion' | 'economic-buyer';
|
||||
export type Source = 'cold' | 'ad' | 'content' | 'referral' | 'website' | 'demo-request';
|
||||
export type PrevContact = 'none' | 'once' | 'well-known';
|
||||
export type LeadStatus = 'cold' | 'warm' | 'hot';
|
||||
export type DealStage =
|
||||
@ -29,6 +29,7 @@ export interface ScoringInput {
|
||||
dm_contact: DmContact;
|
||||
source?: Source;
|
||||
prev_contact?: PrevContact;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface ScoringResult {
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
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';
|
||||
|
||||
const baseInbound: ScoringInput = {
|
||||
lead_type: 'inbound',
|
||||
company_size: '50+',
|
||||
company_size: '200+',
|
||||
budget: 'confirmed',
|
||||
urgency: 'under-3m',
|
||||
dm_contact: 'decision-maker',
|
||||
source: 'website',
|
||||
urgency: 'under-1m',
|
||||
dm_contact: 'economic-buyer',
|
||||
source: 'demo-request',
|
||||
};
|
||||
|
||||
const baseOutbound: ScoringInput = {
|
||||
@ -16,91 +20,120 @@ const baseOutbound: ScoringInput = {
|
||||
company_size: '1-10',
|
||||
budget: 'no',
|
||||
urgency: 'none',
|
||||
dm_contact: 'no',
|
||||
dm_contact: 'unknown',
|
||||
prev_contact: 'none',
|
||||
};
|
||||
|
||||
describe('calculateScore', () => {
|
||||
it('max inbound → 100, hot', () => {
|
||||
const r = calculateScore(baseInbound);
|
||||
expect(r.total).toBe(100);
|
||||
it('max inbound (corporate email) → 95, hot', () => {
|
||||
const r = calculateScore({ ...baseInbound, email: 'ivan@acme.com' });
|
||||
expect(r.total).toBe(95);
|
||||
expect(r.status).toBe('hot');
|
||||
expect(r.breakdown).toEqual({
|
||||
company_size: 25,
|
||||
budget: 25,
|
||||
company_size: 20,
|
||||
dm_contact: 25,
|
||||
budget: 15,
|
||||
urgency: 20,
|
||||
dm_contact: 20,
|
||||
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);
|
||||
expect(r.total).toBe(5);
|
||||
expect(r.total).toBe(4);
|
||||
expect(r.status).toBe('cold');
|
||||
expect(r.breakdown).toEqual({
|
||||
company_size: 5,
|
||||
company_size: 4,
|
||||
dm_contact: 0,
|
||||
budget: 0,
|
||||
urgency: 0,
|
||||
dm_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({
|
||||
lead_type: 'inbound',
|
||||
company_size: '11-50', // 15
|
||||
budget: 'maybe', // 12
|
||||
urgency: '3-6m', // 10
|
||||
dm_contact: 'no', // 0
|
||||
source: 'referral', // 7
|
||||
company_size: '11-50', // 12
|
||||
budget: 'maybe', // 8
|
||||
urgency: '3-6m', // 8
|
||||
dm_contact: 'influencer', // 12
|
||||
source: 'referral', // 8
|
||||
email: 'ceo@startup.co', // +5 corp
|
||||
});
|
||||
expect(r.total).toBe(44);
|
||||
expect(r.total).toBe(53);
|
||||
expect(r.status).toBe('warm');
|
||||
});
|
||||
|
||||
it('cold boundary — 37 is cold', () => {
|
||||
const r = calculateScore({
|
||||
it('cold boundary — 40 is cold, 41 warm', () => {
|
||||
const cold = calculateScore({
|
||||
lead_type: 'outbound',
|
||||
company_size: '11-50', // 15
|
||||
budget: 'maybe', // 12
|
||||
urgency: '3-6m', // 10
|
||||
dm_contact: 'no', // 0
|
||||
prev_contact: 'none', // 0
|
||||
company_size: '51-200', // 18
|
||||
budget: 'maybe', // 8
|
||||
urgency: '3-6m', // 8
|
||||
dm_contact: 'unknown', // 0
|
||||
prev_contact: 'once', // 5
|
||||
});
|
||||
expect(r.total).toBe(37);
|
||||
expect(r.status).toBe('cold');
|
||||
expect(cold.total).toBe(39);
|
||||
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', () => {
|
||||
const r = calculateScore({
|
||||
lead_type: 'outbound',
|
||||
company_size: '50+', // 25
|
||||
budget: 'confirmed', // 25
|
||||
urgency: '3-6m', // 10
|
||||
dm_contact: 'influencer', // 10
|
||||
prev_contact: 'well-known', // 10
|
||||
});
|
||||
expect(r.total).toBe(80);
|
||||
expect(r.status).toBe('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('budget=no no longer caps the score', () => {
|
||||
it('hot — 71+ outbound with champion', () => {
|
||||
const r = calculateScore({
|
||||
lead_type: 'inbound',
|
||||
company_size: '50+', // 25
|
||||
budget: 'no', // 0
|
||||
urgency: 'under-3m', // 20
|
||||
dm_contact: 'decision-maker', // 20
|
||||
source: 'website', // 10
|
||||
lead_type: 'outbound',
|
||||
company_size: '200+', // 20
|
||||
budget: 'confirmed', // 15
|
||||
urgency: '1-3m', // 14
|
||||
dm_contact: 'champion', // 20
|
||||
prev_contact: 'well-known', // 10
|
||||
email: 'cfo@bigcorp.kz', // +5
|
||||
});
|
||||
expect(r.total).toBe(75);
|
||||
expect(r.total).toBe(84);
|
||||
expect(r.status).toBe('hot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyEmail', () => {
|
||||
it('gmail → free', () => expect(classifyEmail('a@gmail.com')).toBe('free'));
|
||||
it('yandex → free', () => expect(classifyEmail('a@yandex.ru')).toBe('free'));
|
||||
it('corp domain → corporate', () => expect(classifyEmail('a@acme.com')).toBe('corporate'));
|
||||
it('empty → empty', () => expect(classifyEmail('')).toBe('empty'));
|
||||
it('null → empty', () => expect(classifyEmail(null)).toBe('empty'));
|
||||
it('no @ → empty', () => expect(classifyEmail('foo')).toBe('empty'));
|
||||
});
|
||||
|
||||
describe('validateScoringInput', () => {
|
||||
it('inbound without source → throws', () => {
|
||||
expect(() =>
|
||||
|
||||
@ -29,3 +29,10 @@ export function updateUser(
|
||||
export function deleteUser(username: string) {
|
||||
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' },
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,11 +2,11 @@ import { apiFetch } from './client';
|
||||
|
||||
export type LeadType = 'inbound' | 'outbound';
|
||||
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 Urgency = 'none' | '3-6m' | 'under-3m';
|
||||
export type DmContact = 'no' | 'influencer' | 'decision-maker';
|
||||
export type Source = 'cold' | 'referral' | 'website';
|
||||
export type Urgency = 'none' | '6m-plus' | '3-6m' | '1-3m' | 'under-1m';
|
||||
export type DmContact = 'unknown' | 'user' | 'influencer' | 'champion' | 'economic-buyer';
|
||||
export type Source = 'cold' | 'ad' | 'content' | 'referral' | 'website' | 'demo-request';
|
||||
export type PrevContact = 'none' | 'once' | 'well-known';
|
||||
export type DealStage =
|
||||
| 'new'
|
||||
|
||||
@ -2,12 +2,17 @@
|
||||
interface Option {
|
||||
value: T;
|
||||
label: string;
|
||||
tip?: string;
|
||||
points?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
modelValue: T | null | undefined;
|
||||
options: Option[];
|
||||
hint?: string;
|
||||
max?: number;
|
||||
earned?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: T): void }>();
|
||||
@ -19,15 +24,28 @@ function select(v: T) {
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||
{{ label }}
|
||||
<span v-if="hint" class="ml-2 text-[10px] text-gray-400 normal-case tracking-normal">
|
||||
{{ hint }}
|
||||
</span>
|
||||
</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">
|
||||
<button
|
||||
v-for="o in options"
|
||||
:key="String(o.value)"
|
||||
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="
|
||||
props.modelValue === o.value
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-sm'
|
||||
@ -35,7 +53,13 @@ function select(v: T) {
|
||||
"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
180
leadscore/frontend/src/scoring.ts
Normal file
180
leadscore/frontend/src/scoring.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
recalcAllLeads,
|
||||
updateUser,
|
||||
type AppUser,
|
||||
} from '@/api/admin';
|
||||
@ -22,6 +23,20 @@ const form = reactive<{ username: string; password: string; role: 'manager' | 'r
|
||||
role: 'manager',
|
||||
});
|
||||
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() {
|
||||
loading.value = true;
|
||||
@ -138,6 +153,25 @@ onMounted(load);
|
||||
</div>
|
||||
</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">
|
||||
<Skeleton :rows="4" height="40px" />
|
||||
</div>
|
||||
|
||||
@ -65,22 +65,26 @@ const stageBadgeClass = computed(() => {
|
||||
|
||||
const breakdownLabels: Record<string, string> = {
|
||||
company_size: 'Размер компании',
|
||||
dm_contact: 'Кто контакт',
|
||||
budget: 'Бюджет',
|
||||
urgency: 'Срочность',
|
||||
dm_contact: 'Контакт — ЛПР',
|
||||
source: 'Источник',
|
||||
prev_contact: 'Предыдущий контакт',
|
||||
email_mod: 'Корп. email',
|
||||
};
|
||||
|
||||
const breakdownMax: Record<string, number> = {
|
||||
company_size: 25,
|
||||
budget: 25,
|
||||
company_size: 20,
|
||||
dm_contact: 25,
|
||||
budget: 15,
|
||||
urgency: 20,
|
||||
dm_contact: 20,
|
||||
source: 10,
|
||||
prev_contact: 10,
|
||||
email_mod: 5,
|
||||
};
|
||||
|
||||
const SCORE_MAX = 95;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@ -330,7 +334,7 @@ onMounted(load);
|
||||
</ul>
|
||||
<div class="flex justify-between pt-2 border-t border-gray-200 font-semibold">
|
||||
<span>Итого</span>
|
||||
<span>{{ lead.score }} / 100</span>
|
||||
<span>{{ lead.score }} / {{ SCORE_MAX }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -18,6 +18,16 @@ import {
|
||||
type Urgency,
|
||||
} from '@/api/leads';
|
||||
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 {
|
||||
contact_name: string;
|
||||
@ -69,44 +79,82 @@ const loading = ref(false);
|
||||
const router = useRouter();
|
||||
|
||||
const leadTypeOpts: { value: LeadType; label: string }[] = [
|
||||
{ value: 'inbound', label: 'Inbound' },
|
||||
{ value: 'outbound', label: 'Outbound' },
|
||||
{ value: 'inbound', label: 'Входящий' },
|
||||
{ value: 'outbound', label: 'Исходящий' },
|
||||
];
|
||||
|
||||
const companySizeOpts: { value: CompanySize; label: string }[] = [
|
||||
{ value: '1-10', label: '1–10' },
|
||||
{ value: '11-50', label: '11–50' },
|
||||
{ value: '50+', label: '50+' },
|
||||
const companySizeOpts: { value: CompanySize; label: string; points: number }[] = [
|
||||
{ value: '1-10', label: '1–10', points: COMPANY_SIZE_WEIGHT['1-10'] },
|
||||
{ value: '11-50', label: '11–50', points: COMPANY_SIZE_WEIGHT['11-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 }[] = [
|
||||
{ value: 'no', label: 'Нет' },
|
||||
{ value: 'maybe', label: 'Возможно' },
|
||||
{ value: 'confirmed', label: 'Подтверждён' },
|
||||
const budgetOpts: { value: Budget; label: string; points: number }[] = [
|
||||
{ value: 'no', label: 'Нет', points: BUDGET_WEIGHT.no },
|
||||
{ value: 'maybe', label: 'Возможно', points: BUDGET_WEIGHT.maybe },
|
||||
{ value: 'confirmed', label: 'Подтверждён', points: BUDGET_WEIGHT.confirmed },
|
||||
];
|
||||
|
||||
const urgencyOpts: { value: Urgency; label: string }[] = [
|
||||
{ value: 'none', label: 'Нет дедлайна' },
|
||||
{ value: '3-6m', label: '3–6 месяцев' },
|
||||
{ value: 'under-3m', label: 'До 3 месяцев' },
|
||||
const urgencyOpts: { value: Urgency; label: string; points: number }[] = [
|
||||
{ value: 'none', label: 'Нет', points: URGENCY_WEIGHT.none },
|
||||
{ value: '6m-plus', label: 'Больше 6 мес', points: URGENCY_WEIGHT['6m-plus'] },
|
||||
{ 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 }[] = [
|
||||
{ value: 'no', label: 'Нет' },
|
||||
{ value: 'influencer', label: 'Влияет на решение' },
|
||||
{ value: 'decision-maker', label: 'Принимает решение' },
|
||||
const dmContactOpts: {
|
||||
value: DmContact;
|
||||
label: string;
|
||||
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 }[] = [
|
||||
{ value: 'cold', label: 'Холодный' },
|
||||
{ value: 'referral', label: 'Реферал' },
|
||||
{ value: 'website', label: 'Сайт / входящий' },
|
||||
const sourceOpts: { value: Source; label: string; points: number; tip?: string }[] = [
|
||||
{ value: 'cold', label: 'Холодный список', points: SOURCE_WEIGHT.cold },
|
||||
{ value: 'ad', label: 'Реклама', points: SOURCE_WEIGHT.ad },
|
||||
{ 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 }[] = [
|
||||
{ value: 'none', label: 'Не было' },
|
||||
{ value: 'once', label: 'Встречались один раз' },
|
||||
{ value: 'well-known', label: 'Знакомы хорошо' },
|
||||
const prevContactOpts: { value: PrevContact; label: string; points: number }[] = [
|
||||
{ value: 'none', label: 'Не было', points: PREV_CONTACT_WEIGHT.none },
|
||||
{ value: 'once', label: 'Встречались один раз', points: PREV_CONTACT_WEIGHT.once },
|
||||
{ value: 'well-known', label: 'Знакомы хорошо', points: PREV_CONTACT_WEIGHT['well-known'] },
|
||||
];
|
||||
|
||||
const PHONE_RE = /^\+?\d[\d\s\-()]{8,}$/;
|
||||
@ -134,9 +182,7 @@ function validateFields(): Record<string, string> {
|
||||
}
|
||||
|
||||
const liveErrors = computed(() => validateFields());
|
||||
|
||||
const canSubmit = computed(() => Object.keys(liveErrors.value).length === 0);
|
||||
|
||||
const attempted = ref(false);
|
||||
|
||||
const displayErrors = computed<Record<string, string>>(() => {
|
||||
@ -144,6 +190,44 @@ const displayErrors = computed<Record<string, string>>(() => {
|
||||
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() {
|
||||
error.value = null;
|
||||
attempted.value = true;
|
||||
@ -200,7 +284,7 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Новый лид</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
@ -208,6 +292,36 @@ async function submit() {
|
||||
</p>
|
||||
</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">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Контакт</h2>
|
||||
@ -242,8 +356,15 @@ async function submit() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
Email <span class="text-red-600">*</span>
|
||||
<label class="block text-sm font-medium mb-1 flex items-center justify-between">
|
||||
<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>
|
||||
<input
|
||||
v-model="form.email"
|
||||
@ -327,8 +448,8 @@ async function submit() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
||||
<section class="space-y-5">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Скоринг <span class="text-red-600">*</span>
|
||||
</h2>
|
||||
<QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" />
|
||||
@ -337,38 +458,57 @@ async function submit() {
|
||||
label="Размер компании"
|
||||
v-model="form.company_size"
|
||||
:options="companySizeOpts"
|
||||
:max="20"
|
||||
:earned="breakdown.company_size ?? 0"
|
||||
/>
|
||||
<p v-if="displayErrors.company_size" class="mt-1 text-xs text-red-600">
|
||||
{{ displayErrors.company_size }}
|
||||
</p>
|
||||
</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">
|
||||
{{ displayErrors.budget }}
|
||||
</p>
|
||||
</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">
|
||||
{{ displayErrors.urgency }}
|
||||
</p>
|
||||
</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'">
|
||||
<QuestionButton
|
||||
label="Источник лида"
|
||||
v-model="form.source"
|
||||
:options="sourceOpts"
|
||||
:max="10"
|
||||
:earned="breakdown.source ?? 0"
|
||||
/>
|
||||
<p v-if="displayErrors.source" class="mt-1 text-xs text-red-600">
|
||||
{{ displayErrors.source }}
|
||||
@ -379,6 +519,8 @@ async function submit() {
|
||||
label="Предыдущий контакт"
|
||||
v-model="form.prev_contact"
|
||||
:options="prevContactOpts"
|
||||
:max="10"
|
||||
:earned="breakdown.prev_contact ?? 0"
|
||||
/>
|
||||
<p v-if="displayErrors.prev_contact" class="mt-1 text-xs text-red-600">
|
||||
{{ displayErrors.prev_contact }}
|
||||
|
||||
55
leadscore/migrations/1700000007000_scoring_v2.sql
Normal file
55
leadscore/migrations/1700000007000_scoring_v2.sql
Normal 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'));
|
||||
Reference in New Issue
Block a user