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:
parent
171fd4636c
commit
45a4102ae4
14
PRD.md
14
PRD.md
@ -19,17 +19,19 @@
|
||||
|---|---|---|---|
|
||||
| Размер компании | ✅ | ✅ | 20 |
|
||||
| Кто наш контакт (MEDDIC) | ✅ | ✅ | 25 |
|
||||
| Бюджет | ✅ | ✅ | 15 |
|
||||
| Бюджет | ✅ | ✅ | 20 |
|
||||
| Срочность / дедлайн | ✅ | ✅ | 20 |
|
||||
| Источник лида | ✅ | — | 10 |
|
||||
| Предыдущий контакт | — | ✅ | 10 |
|
||||
| **Подсумма (вопросы формы)** | | | **90** |
|
||||
| **Подсумма (вопросы формы)** | | | **95** |
|
||||
| Email-модификатор | ✅ | ✅ | ±5 (тихий) |
|
||||
| **Итого, clamp [0..100]** | | | **95** |
|
||||
| **Итого, clamp [0..100]** | | | **100** |
|
||||
|
||||
> Критерий «Соответствие 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 корректно.
|
||||
>
|
||||
> Формула 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
|
||||
- Распорядитель бюджета (economic buyer — подпись на счёте) → 25
|
||||
|
||||
**Бюджет** (0..15)
|
||||
**Бюджет** (0..20)
|
||||
- Нет → 0
|
||||
- Возможно → 8
|
||||
- Подтверждён → 15
|
||||
- Возможно → 10
|
||||
- Подтверждён → 20
|
||||
|
||||
**Срочность** (0..20)
|
||||
- Нет дедлайна → 0
|
||||
|
||||
13
TRD.md
13
TRD.md
@ -132,12 +132,12 @@ type ScoringInput = {
|
||||
};
|
||||
|
||||
type ScoringResult = {
|
||||
total: number; // clamp [0..100]; теоретический максимум формулы — 95
|
||||
total: number; // clamp [0..100]; max в формуле v3 = 100
|
||||
breakdown: Record<string, number>;
|
||||
status: 'cold' | 'warm' | 'hot';
|
||||
};
|
||||
|
||||
export const SCORING_FORMULA_VERSION = 2;
|
||||
export const SCORING_FORMULA_VERSION = 3;
|
||||
```
|
||||
|
||||
### Таблицы баллов (из 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 |
|
||||
| `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 |
|
||||
| `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]` на случай будущих расширений.
|
||||
Сумма вопросов формы: **95** (inbound = 20+25+20+20+10, outbound = 20+25+20+20+10).
|
||||
С email-модификатором: **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`. **При смене весов нужно править обе копии.**
|
||||
|
||||
### Пороги статуса
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { query } from '../db.js';
|
||||
import { calculateScore } from '../scoring.js';
|
||||
import { calculateScore, SCORING_FORMULA_VERSION } from '../scoring.js';
|
||||
import { AppError } from '../errors.js';
|
||||
import { listManagers, requireAuth, requireRole } from '../auth.js';
|
||||
import { audit } from '../audit.js';
|
||||
@ -428,8 +428,8 @@ export async function leadRoutes(app: FastifyInstance) {
|
||||
|
||||
const lead = rows[0]!;
|
||||
await query(
|
||||
`INSERT INTO score_history (lead_id, total, breakdown, changed_by) VALUES ($1,$2,$3,$4)`,
|
||||
[lead.id, total, breakdown, user.sub],
|
||||
`INSERT INTO score_history (lead_id, total, breakdown, changed_by, formula_version) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[lead.id, total, breakdown, user.sub, SCORING_FORMULA_VERSION],
|
||||
);
|
||||
|
||||
return serializeLead(lead, breakdown);
|
||||
@ -540,8 +540,8 @@ export async function leadRoutes(app: FastifyInstance) {
|
||||
|
||||
if (lead.score !== current.score) {
|
||||
await query(
|
||||
`INSERT INTO score_history (lead_id, total, breakdown, changed_by) VALUES ($1,$2,$3,$4)`,
|
||||
[lead.id, total, breakdown, user.sub],
|
||||
`INSERT INTO score_history (lead_id, total, breakdown, changed_by, formula_version) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[lead.id, total, breakdown, user.sub, SCORING_FORMULA_VERSION],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import type {
|
||||
} from './types.js';
|
||||
import { AppError } from './errors.js';
|
||||
|
||||
export const SCORING_FORMULA_VERSION = 2;
|
||||
export const SCORING_FORMULA_VERSION = 3;
|
||||
|
||||
const COMPANY_SIZE: Record<CompanySize, number> = {
|
||||
'1-10': 4,
|
||||
@ -20,7 +20,7 @@ const COMPANY_SIZE: Record<CompanySize, number> = {
|
||||
'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> = {
|
||||
none: 0,
|
||||
@ -56,7 +56,7 @@ const PREV_CONTACT: Record<PrevContact, number> = {
|
||||
export const SCORING_MAX = {
|
||||
company_size: 20,
|
||||
dm_contact: 25,
|
||||
budget: 15,
|
||||
budget: 20,
|
||||
urgency: 20,
|
||||
source: 10,
|
||||
prev_contact: 10,
|
||||
|
||||
@ -25,23 +25,23 @@ const baseOutbound: ScoringInput = {
|
||||
};
|
||||
|
||||
describe('calculateScore', () => {
|
||||
it('max inbound (corporate email) → 95, hot', () => {
|
||||
it('max inbound (corporate email) → 100, hot', () => {
|
||||
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.breakdown).toEqual({
|
||||
company_size: 20,
|
||||
dm_contact: 25,
|
||||
budget: 15,
|
||||
budget: 20,
|
||||
urgency: 20,
|
||||
source: 10,
|
||||
email_mod: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('max inbound (no email) → 90, hot', () => {
|
||||
it('max inbound (no email) → 95, hot', () => {
|
||||
const r = calculateScore(baseInbound);
|
||||
expect(r.total).toBe(90);
|
||||
expect(r.total).toBe(95);
|
||||
expect(r.status).toBe('hot');
|
||||
expect(r.breakdown.email_mod).toBeUndefined();
|
||||
});
|
||||
@ -49,7 +49,7 @@ describe('calculateScore', () => {
|
||||
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);
|
||||
expect(r.total).toBe(90);
|
||||
});
|
||||
|
||||
it('min outbound → 4, cold', () => {
|
||||
@ -65,62 +65,60 @@ describe('calculateScore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('warm inbound — 53', () => {
|
||||
it('warm inbound — 55', () => {
|
||||
const r = calculateScore({
|
||||
lead_type: 'inbound',
|
||||
company_size: '11-50', // 12
|
||||
budget: 'maybe', // 8
|
||||
urgency: '3-6m', // 8
|
||||
company_size: '11-50', // 12
|
||||
budget: 'maybe', // 10
|
||||
urgency: '3-6m', // 8
|
||||
dm_contact: 'influencer', // 12
|
||||
source: 'referral', // 8
|
||||
email: 'ceo@startup.co', // +5 corp
|
||||
source: 'referral', // 8
|
||||
email: 'ceo@startup.co', // +5 corp
|
||||
});
|
||||
expect(r.total).toBe(53);
|
||||
expect(r.total).toBe(55);
|
||||
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({
|
||||
lead_type: 'outbound',
|
||||
company_size: '51-200', // 18
|
||||
budget: 'maybe', // 8
|
||||
urgency: '3-6m', // 8
|
||||
dm_contact: 'unknown', // 0
|
||||
prev_contact: 'once', // 5
|
||||
company_size: '51-200', // 18
|
||||
budget: 'maybe', // 10
|
||||
urgency: 'none', // 0
|
||||
dm_contact: 'influencer', // 12
|
||||
prev_contact: 'none', // 0
|
||||
});
|
||||
expect(cold.total).toBe(39);
|
||||
expect(cold.total).toBe(40);
|
||||
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
|
||||
budget: 'maybe', // 10
|
||||
urgency: '3-6m', // 8
|
||||
dm_contact: 'influencer', // 12
|
||||
prev_contact: 'none', // 0
|
||||
});
|
||||
expect(warm.total).toBe(46);
|
||||
expect(warm.total).toBe(48);
|
||||
expect(warm.status).toBe('warm');
|
||||
});
|
||||
|
||||
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' });
|
||||
const r = calculateScore({ ...baseInbound, email: 'a@acme.com' });
|
||||
expect(r.total).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('hot — 71+ outbound with champion', () => {
|
||||
it('hot — outbound with champion → 89', () => {
|
||||
const r = calculateScore({
|
||||
lead_type: 'outbound',
|
||||
company_size: '200+', // 20
|
||||
budget: 'confirmed', // 15
|
||||
budget: 'confirmed', // 20
|
||||
urgency: '1-3m', // 14
|
||||
dm_contact: 'champion', // 20
|
||||
prev_contact: 'well-known', // 10
|
||||
email: 'cfo@bigcorp.kz', // +5
|
||||
});
|
||||
expect(r.total).toBe(84);
|
||||
expect(r.total).toBe(89);
|
||||
expect(r.status).toBe('hot');
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,7 +19,7 @@ export const COMPANY_SIZE_WEIGHT: Record<CompanySize, number> = {
|
||||
'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> = {
|
||||
none: 0,
|
||||
@ -55,7 +55,7 @@ export const PREV_CONTACT_WEIGHT: Record<PrevContact, number> = {
|
||||
export const SCORING_MAX: Record<string, number> = {
|
||||
company_size: 20,
|
||||
dm_contact: 25,
|
||||
budget: 15,
|
||||
budget: 20,
|
||||
urgency: 20,
|
||||
source: 10,
|
||||
prev_contact: 10,
|
||||
|
||||
@ -76,14 +76,14 @@ const breakdownLabels: Record<string, string> = {
|
||||
const breakdownMax: Record<string, number> = {
|
||||
company_size: 20,
|
||||
dm_contact: 25,
|
||||
budget: 15,
|
||||
budget: 20,
|
||||
urgency: 20,
|
||||
source: 10,
|
||||
prev_contact: 10,
|
||||
email_mod: 5,
|
||||
};
|
||||
|
||||
const SCORE_MAX = 95;
|
||||
const SCORE_MAX = 100;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
|
||||
Reference in New Issue
Block a user