feat(scoring): bump to v3 — max score now 100 (budget rebalanced)

- BUDGET weights raised: no=0, maybe=10, confirmed=20 (was 0/8/15).
  Reason: BANT-B is the most predictive B2B signal; 20% of max-score
  matches industry weighting practice and makes the form max land on
  a round 100 instead of 95.
- SCORING_FORMULA_VERSION → 3. No DB migration needed (weight change
  only); all score_history INSERTs now write formula_version
  explicitly (POST /leads, PATCH /leads/:id, admin recalc).
- Frontend mirror (frontend/src/scoring.ts) + LeadCardView SCORE_MAX
  updated to 100. NewLeadView live-preview picks up new weights via
  BUDGET_WEIGHT.
- Tests reworked: 18/18 passing. Boundary tests rewritten because
  maybe 8 -> 10 pushed prior cold-39 case into warm.
- PRD/TRD: new totals (95 form + 5 email-mod = 100 cap), updated
  weight tables, v2 -> v3 changelog entry.

To apply on existing leads: POST /api/admin/leads/recalc (rop-only,
"Пересчитать всё" button in /admin/users).
This commit is contained in:
Telecom 2026-05-22 12:44:05 +05:00
parent 171fd4636c
commit 45a4102ae4
7 changed files with 56 additions and 55 deletions

14
PRD.md
View File

@ -19,17 +19,19 @@
|---|---|---|---| |---|---|---|---|
| Размер компании | ✅ | ✅ | 20 | | Размер компании | ✅ | ✅ | 20 |
| Кто наш контакт (MEDDIC) | ✅ | ✅ | 25 | | Кто наш контакт (MEDDIC) | ✅ | ✅ | 25 |
| Бюджет | ✅ | ✅ | 15 | | Бюджет | ✅ | ✅ | 20 |
| Срочность / дедлайн | ✅ | ✅ | 20 | | Срочность / дедлайн | ✅ | ✅ | 20 |
| Источник лида | ✅ | — | 10 | | Источник лида | ✅ | — | 10 |
| Предыдущий контакт | — | ✅ | 10 | | Предыдущий контакт | — | ✅ | 10 |
| **Подсумма (вопросы формы)** | | | **90** | | **Подсумма (вопросы формы)** | | | **95** |
| Email-модификатор | ✅ | ✅ | ±5 (тихий) | | Email-модификатор | ✅ | ✅ | ±5 (тихий) |
| **Итого, clamp [0..100]** | | | **95** | | **Итого, clamp [0..100]** | | | **100** |
> Критерий «Соответствие ICP» убран из формулы (см. миграцию `1700000004000_drop_icp_fit.sql`): ICP плохо квантуется в момент ввода и часто превращался в «галочку наугад». > Критерий «Соответствие 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 корректно. > Формула v2 (см. `1700000007000_scoring_v2.sql`): размер компании теперь 4 уровня, шкала контакта расширена до MEDDIC-лестницы (5 уровней), срочность — 5 уровней, источник — 6 уровней. Email-домен добавляет ±5 как тихий модификатор (не требует ввода в форме). Score хранится с привязкой к `formula_version` в `score_history` — старые записи v1 сравниваются с новыми v2 корректно.
>
> Формула v3 (без миграции БД, чисто code-change): вес `budget` поднят 15 → 20 (`no:0, maybe:10, confirmed:20`), чтобы суммарный максимум вышел ровно на **100**. Бюджет — самый предсказательный сигнал BANT; вес теперь 20 % от total. Прежние записи остаются v2 в `score_history.formula_version`; после прогона `POST /admin/leads/recalc` все живые лиды получают v3-балл.
### Детальная логика баллов ### Детальная логика баллов
@ -46,10 +48,10 @@
- Внутренний адвокат (champion — продаёт за нас внутри) → 20 - Внутренний адвокат (champion — продаёт за нас внутри) → 20
- Распорядитель бюджета (economic buyer — подпись на счёте) → 25 - Распорядитель бюджета (economic buyer — подпись на счёте) → 25
**Бюджет** (0..15) **Бюджет** (0..20)
- Нет → 0 - Нет → 0
- Возможно → 8 - Возможно → 10
- Подтверждён → 15 - Подтверждён → 20
**Срочность** (0..20) **Срочность** (0..20)
- Нет дедлайна → 0 - Нет дедлайна → 0

13
TRD.md
View File

@ -132,12 +132,12 @@ type ScoringInput = {
}; };
type ScoringResult = { type ScoringResult = {
total: number; // clamp [0..100]; теоретический максимум формулы — 95 total: number; // clamp [0..100]; max в формуле v3 = 100
breakdown: Record<string, number>; breakdown: Record<string, number>;
status: 'cold' | 'warm' | 'hot'; status: 'cold' | 'warm' | 'hot';
}; };
export const SCORING_FORMULA_VERSION = 2; export const SCORING_FORMULA_VERSION = 3;
``` ```
### Таблицы баллов (из PRD §«Детальная логика») ### Таблицы баллов (из PRD §«Детальная логика»)
@ -146,16 +146,17 @@ export const SCORING_FORMULA_VERSION = 2;
|---|---|---| |---|---|---|
| `company_size` | 20 | `1-10`→4, `11-50`→12, `51-200`→18, `200+`→20 | | `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 | | `dm_contact` | 25 | `unknown`→0, `user`→5, `influencer`→12, `champion`→20, `economic-buyer`→25 |
| `budget` | 15 | `no`→0, `maybe`→8, `confirmed`→15 | | `budget` | 20 | `no`→0, `maybe`→10, `confirmed`→20 |
| `urgency` | 20 | `none`→0, `6m-plus`→3, `3-6m`→8, `1-3m`→14, `under-1m`→20 | | `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 | | `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 | | `prev_contact` (outbound) | 10 | `none`→0, `once`→5, `well-known`→10 |
| `email_mod` (тихий, из домена) | ±5 | корп. → +5, free-mail → −5, пусто → 0 | | `email_mod` (тихий, из домена) | ±5 | корп. → +5, free-mail → −5, пусто → 0 |
Сумма вопросов формы: **90** (inbound = 20+25+15+20+10, outbound = 20+25+15+20+10). Сумма вопросов формы: **95** (inbound = 20+25+20+20+10, outbound = 20+25+20+20+10).
С email-модификатором: **95** макс. С email-модификатором: **100** макс.
Итог clamp в `[0..100]` на случай будущих расширений. Итог clamp в `[0..100]`.
`SCORING_FORMULA_VERSION = 3` (v2 → v3 bump = ребаланс веса `budget`, без миграции БД).
Список free-mail доменов хранится в `backend/src/scoring.ts → FREE_MAIL_DOMAINS`. Зеркало для live-предпросмотра в форме — `frontend/src/scoring.ts`. **При смене весов нужно править обе копии.** Список free-mail доменов хранится в `backend/src/scoring.ts → FREE_MAIL_DOMAINS`. Зеркало для live-предпросмотра в форме — `frontend/src/scoring.ts`. **При смене весов нужно править обе копии.**
### Пороги статуса ### Пороги статуса

View File

@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { query } from '../db.js'; import { query } from '../db.js';
import { calculateScore } from '../scoring.js'; import { calculateScore, SCORING_FORMULA_VERSION } from '../scoring.js';
import { AppError } from '../errors.js'; import { AppError } from '../errors.js';
import { listManagers, requireAuth, requireRole } from '../auth.js'; import { listManagers, requireAuth, requireRole } from '../auth.js';
import { audit } from '../audit.js'; import { audit } from '../audit.js';
@ -428,8 +428,8 @@ export async function leadRoutes(app: FastifyInstance) {
const lead = rows[0]!; const lead = rows[0]!;
await query( await query(
`INSERT INTO score_history (lead_id, total, breakdown, changed_by) VALUES ($1,$2,$3,$4)`, `INSERT INTO score_history (lead_id, total, breakdown, changed_by, formula_version) VALUES ($1,$2,$3,$4,$5)`,
[lead.id, total, breakdown, user.sub], [lead.id, total, breakdown, user.sub, SCORING_FORMULA_VERSION],
); );
return serializeLead(lead, breakdown); return serializeLead(lead, breakdown);
@ -540,8 +540,8 @@ export async function leadRoutes(app: FastifyInstance) {
if (lead.score !== current.score) { if (lead.score !== current.score) {
await query( await query(
`INSERT INTO score_history (lead_id, total, breakdown, changed_by) VALUES ($1,$2,$3,$4)`, `INSERT INTO score_history (lead_id, total, breakdown, changed_by, formula_version) VALUES ($1,$2,$3,$4,$5)`,
[lead.id, total, breakdown, user.sub], [lead.id, total, breakdown, user.sub, SCORING_FORMULA_VERSION],
); );
} }

View File

@ -11,7 +11,7 @@ import type {
} from './types.js'; } from './types.js';
import { AppError } from './errors.js'; import { AppError } from './errors.js';
export const SCORING_FORMULA_VERSION = 2; export const SCORING_FORMULA_VERSION = 3;
const COMPANY_SIZE: Record<CompanySize, number> = { const COMPANY_SIZE: Record<CompanySize, number> = {
'1-10': 4, '1-10': 4,
@ -20,7 +20,7 @@ const COMPANY_SIZE: Record<CompanySize, number> = {
'200+': 20, '200+': 20,
}; };
const BUDGET: Record<Budget, number> = { no: 0, maybe: 8, confirmed: 15 }; const BUDGET: Record<Budget, number> = { no: 0, maybe: 10, confirmed: 20 };
const URGENCY: Record<Urgency, number> = { const URGENCY: Record<Urgency, number> = {
none: 0, none: 0,
@ -56,7 +56,7 @@ const PREV_CONTACT: Record<PrevContact, number> = {
export const SCORING_MAX = { export const SCORING_MAX = {
company_size: 20, company_size: 20,
dm_contact: 25, dm_contact: 25,
budget: 15, budget: 20,
urgency: 20, urgency: 20,
source: 10, source: 10,
prev_contact: 10, prev_contact: 10,

View File

@ -25,23 +25,23 @@ const baseOutbound: ScoringInput = {
}; };
describe('calculateScore', () => { describe('calculateScore', () => {
it('max inbound (corporate email) → 95, hot', () => { it('max inbound (corporate email) → 100, hot', () => {
const r = calculateScore({ ...baseInbound, email: 'ivan@acme.com' }); const r = calculateScore({ ...baseInbound, email: 'ivan@acme.com' });
expect(r.total).toBe(95); expect(r.total).toBe(100);
expect(r.status).toBe('hot'); expect(r.status).toBe('hot');
expect(r.breakdown).toEqual({ expect(r.breakdown).toEqual({
company_size: 20, company_size: 20,
dm_contact: 25, dm_contact: 25,
budget: 15, budget: 20,
urgency: 20, urgency: 20,
source: 10, source: 10,
email_mod: 5, email_mod: 5,
}); });
}); });
it('max inbound (no email) → 90, hot', () => { it('max inbound (no email) → 95, hot', () => {
const r = calculateScore(baseInbound); const r = calculateScore(baseInbound);
expect(r.total).toBe(90); expect(r.total).toBe(95);
expect(r.status).toBe('hot'); expect(r.status).toBe('hot');
expect(r.breakdown.email_mod).toBeUndefined(); expect(r.breakdown.email_mod).toBeUndefined();
}); });
@ -49,7 +49,7 @@ describe('calculateScore', () => {
it('free-mail penalty subtracts 5', () => { it('free-mail penalty subtracts 5', () => {
const r = calculateScore({ ...baseInbound, email: 'someone@gmail.com' }); const r = calculateScore({ ...baseInbound, email: 'someone@gmail.com' });
expect(r.breakdown.email_mod).toBe(-5); expect(r.breakdown.email_mod).toBe(-5);
expect(r.total).toBe(85); expect(r.total).toBe(90);
}); });
it('min outbound → 4, cold', () => { it('min outbound → 4, cold', () => {
@ -65,62 +65,60 @@ describe('calculateScore', () => {
}); });
}); });
it('warm inbound — 53', () => { it('warm inbound — 55', () => {
const r = calculateScore({ const r = calculateScore({
lead_type: 'inbound', lead_type: 'inbound',
company_size: '11-50', // 12 company_size: '11-50', // 12
budget: 'maybe', // 8 budget: 'maybe', // 10
urgency: '3-6m', // 8 urgency: '3-6m', // 8
dm_contact: 'influencer', // 12 dm_contact: 'influencer', // 12
source: 'referral', // 8 source: 'referral', // 8
email: 'ceo@startup.co', // +5 corp email: 'ceo@startup.co', // +5 corp
}); });
expect(r.total).toBe(53); expect(r.total).toBe(55);
expect(r.status).toBe('warm'); expect(r.status).toBe('warm');
}); });
it('cold boundary — 40 is cold, 41 warm', () => { it('cold boundary — 40 is cold, 48 is warm', () => {
const cold = calculateScore({ const cold = calculateScore({
lead_type: 'outbound', lead_type: 'outbound',
company_size: '51-200', // 18 company_size: '51-200', // 18
budget: 'maybe', // 8 budget: 'maybe', // 10
urgency: '3-6m', // 8 urgency: 'none', // 0
dm_contact: 'unknown', // 0 dm_contact: 'influencer', // 12
prev_contact: 'once', // 5 prev_contact: 'none', // 0
}); });
expect(cold.total).toBe(39); expect(cold.total).toBe(40);
expect(cold.status).toBe('cold'); expect(cold.status).toBe('cold');
const warm = calculateScore({ const warm = calculateScore({
lead_type: 'outbound', lead_type: 'outbound',
company_size: '51-200', // 18 company_size: '51-200', // 18
budget: 'confirmed', // 15 budget: 'maybe', // 10
urgency: '3-6m', // 8 urgency: '3-6m', // 8
dm_contact: 'unknown', // 0 dm_contact: 'influencer', // 12
prev_contact: 'once', // 5 prev_contact: 'none', // 0
}); });
expect(warm.total).toBe(46); expect(warm.total).toBe(48);
expect(warm.status).toBe('warm'); expect(warm.status).toBe('warm');
}); });
it('clamps total to 100 max', () => { it('clamps total to 100 max', () => {
// synthetic ceiling test: max ever achievable is 95, so test the clamp const r = calculateScore({ ...baseInbound, email: 'a@acme.com' });
// 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); expect(r.total).toBeLessThanOrEqual(100);
}); });
it('hot — 71+ outbound with champion', () => { it('hot — outbound with champion → 89', () => {
const r = calculateScore({ const r = calculateScore({
lead_type: 'outbound', lead_type: 'outbound',
company_size: '200+', // 20 company_size: '200+', // 20
budget: 'confirmed', // 15 budget: 'confirmed', // 20
urgency: '1-3m', // 14 urgency: '1-3m', // 14
dm_contact: 'champion', // 20 dm_contact: 'champion', // 20
prev_contact: 'well-known', // 10 prev_contact: 'well-known', // 10
email: 'cfo@bigcorp.kz', // +5 email: 'cfo@bigcorp.kz', // +5
}); });
expect(r.total).toBe(84); expect(r.total).toBe(89);
expect(r.status).toBe('hot'); expect(r.status).toBe('hot');
}); });
}); });

View File

@ -19,7 +19,7 @@ export const COMPANY_SIZE_WEIGHT: Record<CompanySize, number> = {
'200+': 20, '200+': 20,
}; };
export const BUDGET_WEIGHT: Record<Budget, number> = { no: 0, maybe: 8, confirmed: 15 }; export const BUDGET_WEIGHT: Record<Budget, number> = { no: 0, maybe: 10, confirmed: 20 };
export const URGENCY_WEIGHT: Record<Urgency, number> = { export const URGENCY_WEIGHT: Record<Urgency, number> = {
none: 0, none: 0,
@ -55,7 +55,7 @@ export const PREV_CONTACT_WEIGHT: Record<PrevContact, number> = {
export const SCORING_MAX: Record<string, number> = { export const SCORING_MAX: Record<string, number> = {
company_size: 20, company_size: 20,
dm_contact: 25, dm_contact: 25,
budget: 15, budget: 20,
urgency: 20, urgency: 20,
source: 10, source: 10,
prev_contact: 10, prev_contact: 10,

View File

@ -76,14 +76,14 @@ const breakdownLabels: Record<string, string> = {
const breakdownMax: Record<string, number> = { const breakdownMax: Record<string, number> = {
company_size: 20, company_size: 20,
dm_contact: 25, dm_contact: 25,
budget: 15, budget: 20,
urgency: 20, urgency: 20,
source: 10, source: 10,
prev_contact: 10, prev_contact: 10,
email_mod: 5, email_mod: 5,
}; };
const SCORE_MAX = 95; const SCORE_MAX = 100;
async function load() { async function load() {
loading.value = true; loading.value = true;