Files
CRM-LeadRadar/apps/api/src/archive.int.test.ts
T
yuriandClaude Opus 5.5 9749da5ae7 Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:11:04 +04:00

461 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { events, leadNotifications, leads, listings, sessions, users } from './db/schema.ts';
import { runIngest, runRemovalCheck, type PagedSource } from './ingest/service.ts';
import { runLeadJobs } from './leads/service.ts';
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
import type { SourcedListing } from './parser/source.ts';
import { processOutbox } from './telegram/outbox.ts';
import { fakeTelegram, testApp } from './testing.ts';
// База бывших клиентов и объектов, отметка «Снято с List.am», повторная публикация, аналитика.
let t: Awaited<ReturnType<typeof testApp>>;
let tgId = 9000;
let extId = 2_000_000;
const pages = new Map<number, SourcedListing[]>();
/** Что «ответит» list.am на проверку объявления: false — снято, undefined — понять не удалось. */
const alive = new Map<string, boolean | undefined>();
const source: PagedSource = {
name: 'fake',
listPage: async (category, page = 1) => (page === 1 ? (pages.get(category.id) ?? []) : []),
inspect: async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) }),
isAlive: async (id) => (alive.has(id) ? alive.get(id) : true),
};
const SALE = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const listing = (over: Partial<SourcedListing> = {}): SourcedListing => {
const id = String(++extId);
return {
externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '3-комн. квартира', dealType: 'sale',
propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', address: `ул. Абовяна ${id}`, price: 150_000, currency: 'USD',
rooms: 3, area: 80, floor: 4, floorsTotal: 9, isAgency: false, photoUrls: [], ...over,
};
};
beforeAll(async () => {
t = await testApp('archive', source);
// Категория должна быть непустой, чтобы первый проход по ней состоялся.
pages.set(SALE.id, [listing({ district: 'Нубарашен' })]);
});
afterAll(() => t.close());
const tg = fakeTelegram();
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
const publish = async (...items: SourcedListing[]) => {
pages.set(SALE.id, [...items, ...(pages.get(SALE.id) ?? [])]);
const report = await runIngest({ db: t.db, source });
await flush();
return report;
};
/** Объявление сняли: его нет ни на странице объявления, ни в списке категории. */
const unpublish = (item: SourcedListing) => {
alive.set(item.externalId, false);
pages.set(SALE.id, (pages.get(SALE.id) ?? []).filter((x) => x.externalId !== item.externalId));
};
interface Member { api: ReturnType<typeof t.client>; id: number; chat: number }
const company = async (agents = 1, plan: 'solo' | 'team' = 'team') => {
// Объявления прошлых сценариев «состариваем», чтобы новый фильтр не забрал их из базы.
await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null });
const [sa] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
const admin = t.client(await createSession(t.db, sa!.id));
const created = await admin.post('/api/admin/companies', {
name: 'Агентство', plan, billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
const join = async (name: string, token: string): Promise<Member> => {
const api = await t.register(name);
await api.post(`/api/invites/${token}/accept`);
const id = (await api.get('/api/me')).body.user.id as number;
const chat = ++tgId;
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
return { api, id, chat };
};
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
// Solo работает только из Mini App: помечаем сессию как открытую в Telegram.
if (plan === 'solo') await t.db.update(sessions).set({ viaTelegram: true }).where(eq(sessions.userId, boss.id));
const team: Member[] = [];
if (agents) {
const invite = (await boss.api.post('/api/invites')).body.token as string;
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite));
}
await boss.api.post('/api/filters', { name: 'Кентрон', scope: 'company', params: { districts: ['Кентрон'] } });
await runIngest({ db: t.db, source });
await flush();
const p = async (name: 'object' | 'client') => {
const info = (await boss.api.get(`/api/pipelines/${name}`)).body;
return { ...info, status: (code: string) => info.statuses.find((s: any) => s.code === code).id as number };
};
return { boss, agents: team, id: created.body.id as number, object: await p('object'), client: await p('client') };
};
const poolOf = async (m: Member) => (await m.api.get('/api/leads?tab=pool')).body as any[];
const deal = { commission: 3000, currency: 'USD', date: new Date().toISOString().slice(0, 10) };
const day = 86_400_000;
describe('снятые и повторно опубликованные объявления', () => {
it('раз в сутки объект из CRM проверяется на list.am: снятый получает отметку и дату', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
await a.api.post(`/api/leads/${lead.id}/take`);
// Свежее объявление пока не проверяется.
expect(await runRemovalCheck({ db: t.db, source })).toEqual({ checked: 0, removed: 0 });
const tomorrow = new Date(Date.now() + day + 60_000);
alive.set(item.externalId, undefined);
expect(await runRemovalCheck({ db: t.db, source, now: tomorrow })).toMatchObject({ removed: 0 });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull();
// Понять не удалось — повтор через час, а не через сутки.
unpublish(item);
expect(await runRemovalCheck({ db: t.db, source, now: new Date(tomorrow.getTime() + 30 * 60_000) })).toMatchObject({ removed: 0 });
const later = new Date(tomorrow.getTime() + 61 * 60_000);
expect(await runRemovalCheck({ db: t.db, source, now: later })).toMatchObject({ removed: 1 });
expect(new Date((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).getTime()).toBe(later.getTime());
// Снятое больше не проверяется.
expect(await runRemovalCheck({ db: t.db, source, now: new Date(later.getTime() + 2 * day) })).toMatchObject({ removed: 0 });
});
it('свежее объявление первый раз проверяется через 4 часа после находки', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
await a.api.post(`/api/leads/${lead.id}/take`);
unpublish(item);
const hour = 3_600_000;
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 3 * hour) })).toMatchObject({ removed: 0 });
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 4 * hour + 60_000) })).toMatchObject({ removed: 1 });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).not.toBeNull();
});
it('«Объявления больше нет»: отметка у всех компаний, объявление снова в списке — вернулось на сайт', async () => {
const first = await company(1);
const second = await company(1);
const [a] = first.agents as [Member];
const [b] = second.agents as [Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
const [other] = await poolOf(b);
// Отметить можно и объект из пула.
expect((await a.api.post(`/api/leads/${lead.id}/removed`)).status).toBe(200);
const card = (await a.api.get(`/api/leads/${lead.id}`)).body;
expect(card.removedAt).not.toBeNull();
expect(card.events.map((e: any) => e.type)).toContain('listing_removed');
expect((await b.api.get(`/api/leads/${other.id}`)).body.removedAt).not.toBeNull();
// Ошибочная отметка: объявление всё ещё в списке list.am, и следующий проход парсера её снимает.
await runIngest({ db: t.db, source });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull();
// У объекта, заведённого вручную, объявления нет.
const manual = (await a.api.post('/api/leads', { title: 'Дом в Гарни' })).body.id;
expect((await a.api.post(`/api/leads/${manual}/removed`)).status).toBe(400);
});
it('снятое объявление: при настройке компании открытые лиды сами уходят в «Отказ», у других — без изменений', async () => {
const on = await company(1);
const off = await company(1);
expect((await on.boss.api.patch('/api/company', { autoRejectRemoved: true })).status).toBe(200);
const [a] = on.agents as [Member];
const [b] = off.agents as [Member];
const taken = listing();
const pooled = listing();
await publish(taken, pooled);
const leadOf = async (m: Member, item: SourcedListing) => (await poolOf(m)).find((l) => l.url === item.sourceUrl);
const mine = await leadOf(a, taken);
const inPool = await leadOf(a, pooled);
const other = await leadOf(b, taken);
await a.api.post(`/api/leads/${mine.id}/take`);
// Проверка list.am увидела, что объявление сняли.
unpublish(taken);
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * 3_600_000) })).toMatchObject({ removed: 1 });
const card = (await a.api.get(`/api/leads/${mine.id}`)).body;
expect(card.statusId).toBe(on.object.status('rejected'));
expect(on.object.reasons.find((r: any) => r.id === card.rejectReasonId)).toMatchObject({ code: 'gone', name: 'Уже продано или сдано' });
expect(card.events.some((e: any) => e.type === 'status' && e.text === 'Объявление снято с List.am')).toBe(true);
expect((await b.api.get(`/api/leads/${other.id}`)).body.statusId).toBe(off.object.status('new'));
// Отметка агента «Объявления больше нет» — лид из пула тоже уходит в «Отказ» и пропадает из пула.
expect((await a.api.post(`/api/leads/${inPool.id}/removed`)).status).toBe(200);
expect((await a.api.get(`/api/leads/${inPool.id}`)).body.statusId).toBe(on.object.status('rejected'));
expect((await poolOf(a)).map((l) => l.id)).not.toContain(inPool.id);
await on.boss.api.patch('/api/company', { autoRejectRemoved: false });
});
it('правка объекта: ответственный и руководитель меняют данные карточки, общая база не меняется', async () => {
const c = await company(2);
const [a, b] = c.agents as [Member, Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
// Объект в пуле агент не правит — сначала «Беру».
expect((await a.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(403);
await a.api.post(`/api/leads/${lead.id}/take`);
// Чужой объект другой агент даже не видит.
expect((await b.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(404);
expect((await a.api.patch(`/api/leads/${lead.id}`, { title: '2-комн. квартира', price: 900, rooms: '', currency: 'AMD' })).status).toBe(200);
const card = (await a.api.get(`/api/leads/${lead.id}`)).body;
expect(card).toMatchObject({ title: '2-комн. квартира', price: 900, rooms: null, currency: 'AMD', area: 80, district: 'Кентрон' });
expect(card.events.filter((e: any) => e.type === 'edited')).toHaveLength(1);
// Текст для клиента собран из новых данных.
expect(card.clientText).not.toContain('Комнат');
// Повтор тех же значений историю не засоряет.
await a.api.patch(`/api/leads/${lead.id}`, { price: 900 });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.events.filter((e: any) => e.type === 'edited')).toHaveLength(1);
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { floor: 5 })).status).toBe(200);
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { title: '' })).status).toBe(400);
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, {})).status).toBe(400);
const [base] = await t.db.select().from(listings).where(eq(listings.externalId, item.externalId));
expect(base).toMatchObject({ title: '3-комн. квартира', price: 150_000, rooms: 3, floor: 4 });
});
it('снятое объявление опубликовано снова под тем же номером: новый лид со ссылкой на прошлый', async () => {
const closed = await company(1);
const open = await company(1);
const [a] = closed.agents as [Member];
const [b] = open.agents as [Member];
const item = listing();
await publish(item);
const first = (await poolOf(a))[0];
await a.api.post(`/api/leads/${first.id}/take`);
await a.api.post(`/api/leads/${first.id}/status`, { statusId: closed.object.status('rejected'), rejectReasonId: closed.object.reasons[1].id });
const kept = (await poolOf(b))[0];
await b.api.post(`/api/leads/${kept.id}/take`);
unpublish(item);
const removedAt = new Date(Date.now() + day + 60_000);
await runRemovalCheck({ db: t.db, source, now: removedAt });
// Пока объявление снято, повторный проход парсера ничего не создаёт.
expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 });
const before = tg.inbox(a.chat).length;
alive.delete(item.externalId);
const report = await publish(item);
// Новый лид получает только компания, у которой прошлый закрыт.
expect(report.leads).toBe(1);
expect(await poolOf(b)).toHaveLength(0);
const again = (await poolOf(a))[0];
expect(again.id).not.toBe(first.id);
expect(tg.inbox(a.chat).length).toBe(before + 1);
const card = (await a.api.get(`/api/leads/${again.id}`)).body;
expect(card.removedAt).toBeNull();
expect(card.previous).toMatchObject({ id: first.id, assigneeName: 'Агент 1', status: 'Отказ' });
expect(new Date(card.previous.removedAt).getTime()).toBe(removedAt.getTime());
// Следующие проходы лид не дублируют.
expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 });
expect(await poolOf(a)).toHaveLength(1);
});
it('тот же объект под новым номером: совпали адрес и характеристики, прошлое объявление снято', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const old = listing({ address: 'ул. Туманяна 12' });
await publish(old);
const first = (await poolOf(a))[0];
await a.api.post(`/api/leads/${first.id}/take`);
// Пока прошлое объявление на сайте, совпадение адреса ничего не значит (соседняя квартира).
await publish(listing({ address: 'ул. Туманяна 12' }));
const neighbour = (await poolOf(a))[0];
expect((await a.api.get(`/api/leads/${neighbour.id}`)).body.previous).toBeNull();
await a.api.post(`/api/leads/${neighbour.id}/take`);
unpublish(old);
await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + day + 60_000) });
await publish(listing({ address: ' УЛ. Туманяна 12 ', price: 140_000 }), listing({ address: 'ул. Туманяна 12', rooms: 2 }));
const pool = await poolOf(a);
expect(pool).toHaveLength(2);
const cards = await Promise.all(pool.map(async (l) => (await a.api.get(`/api/leads/${l.id}`)).body));
expect(cards.find((x) => x.rooms === 3).previous).toMatchObject({ id: first.id, status: 'Взят в работу' });
expect(cards.find((x) => x.rooms === 2).previous).toBeNull();
});
});
describe('база бывших клиентов и объектов', () => {
it('закрытые сделкой и отказом объекты и клиенты попадают в базу; Solo базы и аналитики не видит', async () => {
const c = await company(2);
const [a1, a2] = c.agents as [Member, Member];
const mk = async (m: Member, over: object) => (await m.api.post('/api/leads', { title: 'Дом в Арабкире', district: 'Арабкир', price: 300_000, ...over })).body.id as number;
const sold = await mk(a1, { phone: '091 111 222', authorName: 'Гагик' });
const lost = await mk(a1, { phone: '091 111 222', title: 'Квартира Гагика' });
const working = await mk(a1, { title: 'В работе' });
const foreign = await mk(a2, { title: 'Чужой', phone: '077 333 444' });
const buyer = (await a1.api.post('/api/clients', { name: 'Анна', phone: '055 000 111', kind: 'buyer' })).body.id as number;
await a1.api.post(`/api/clients/${buyer}/leads`, { leadId: sold });
await a1.api.post(`/api/leads/${sold}/status`, { statusId: c.object.status('deal'), deal: { ...deal, clientId: buyer } });
await a1.api.post(`/api/leads/${lost}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[2].id });
await a2.api.post(`/api/leads/${foreign}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[1].id });
const mine = (await a1.api.get('/api/archive/leads')).body;
expect(mine.map((l: any) => l.id).sort()).toEqual([sold, lost].sort());
expect(mine.find((l: any) => l.id === sold)).toMatchObject({ result: 'deal', commission: 3000, dealCurrency: 'USD', phone: '+37491111222' });
expect(mine.find((l: any) => l.id === lost)).toMatchObject({ result: 'rejected', reason: c.object.reasons[2].name, removedAt: null });
expect(mine.some((l: any) => l.id === working)).toBe(false);
expect((await a1.api.get('/api/archive/leads?result=deal')).body).toHaveLength(1);
expect((await a1.api.get('/api/archive/leads?q=111222')).body).toHaveLength(2);
expect((await c.boss.api.get('/api/archive/leads')).body).toHaveLength(3);
expect((await c.boss.api.get(`/api/archive/leads?assigneeId=${a2.id}`)).body).toMatchObject([{ id: foreign }]);
// Клиенты: только закрытые покупатели и арендаторы, собственники закрытых объектов сюда не попадают.
const contacts = (await a1.api.get('/api/archive/contacts')).body;
expect(contacts).toHaveLength(1);
expect(contacts[0]).toMatchObject({ type: 'buyer', name: 'Анна', phone: '+37455000111', result: 'deal', objects: [{ id: sold }] });
expect((await c.boss.api.get('/api/archive/contacts')).body).toHaveLength(1);
expect((await a1.api.get('/api/archive/contacts?q=Анна')).body).toHaveLength(1);
const solo = await company(0, 'solo');
expect((await solo.boss.api.get('/api/archive/leads')).body.code).toBe('MODULE_NOT_AVAILABLE');
expect((await solo.boss.api.get('/api/archive/contacts')).status).toBe(403);
expect((await solo.boss.api.get(`/api/analytics?from=${new Date(0).toISOString()}&to=${new Date().toISOString()}`)).body.code).toBe('MODULE_NOT_AVAILABLE');
});
});
describe('суперадмин: общая база объявлений', () => {
it('суперадмин видит все объявления с фильтрами и кому они ушли лидом; руководителю раздел закрыт', async () => {
const c = await company(1);
const hit = listing({ title: 'Особняк на Туманяна', price: 777_000 });
const miss = listing({ district: 'Нубарашен', isAgency: true, title: 'Склад' });
await publish(hit, miss);
const [sa] = await t.db.insert(users).values({ name: 'Владелец', role: 'superadmin', email: `own${Math.random()}@test.am`, passwordHash: 'x' }).returning();
const admin = t.client(await createSession(t.db, sa!.id));
const all = (await admin.get('/api/admin/listings')).body;
expect(all.total).toBeGreaterThanOrEqual(2);
expect(all.items.length).toBe(Math.min(all.total, 50));
const found = (await admin.get(`/api/admin/listings?q=${hit.externalId}`)).body;
expect(found).toMatchObject({ total: 1, items: [{ title: 'Особняк на Туманяна', price: 777_000, active: true }] });
// Лид получили все компании с подходящим фильтром, включая компании прошлых сценариев.
expect(found.items[0].leads).toBeGreaterThanOrEqual(1);
expect((await admin.get(`/api/admin/listings?q=${encodeURIComponent('особняк')}&leads=no`)).body.total).toBe(0);
const agencies = (await admin.get('/api/admin/listings?author=agency&leads=no')).body.items;
expect(agencies.map((l: any) => l.externalId)).toContain(miss.externalId);
expect((await admin.get('/api/admin/listings?priceFrom=700000&priceTo=800000')).body.items.map((l: any) => l.externalId)).toEqual([hit.externalId]);
const card = (await admin.get(`/api/admin/listings/${found.items[0].id}`)).body;
expect(card.leads).toHaveLength(found.items[0].leads);
expect(card.leads[0]).toMatchObject({ company: 'Агентство', status: 'Новый', assignee: null });
expect((await c.boss.api.get('/api/admin/listings')).status).toBe(403);
expect((await c.boss.api.get(`/api/admin/listings/${found.items[0].id}`)).status).toBe(403);
// Настройки парсера: ключ ридера наружу не отдаётся; правка одного поля не трогает остальные.
expect((await c.boss.api.get('/api/admin/parser/settings')).status).toBe(403);
const before = (await admin.get('/api/admin/parser/settings')).body;
expect(before).toMatchObject({ enabled: true, freshDays: 3, pauseSec: 5, maxPages: 3, removalCheck: true, jinaKey: { source: 'none', tail: null } });
expect(before.categories).toHaveLength(10);
const saved = (await admin.patch('/api/admin/parser/settings', { jinaApiKey: 'jina_secret_1234', pauseSec: 30 })).body;
expect(saved).toMatchObject({ pauseSec: 30, freshDays: 3, jinaKey: { source: 'settings', tail: '1234' } });
expect(JSON.stringify(saved)).not.toContain('jina_secret');
expect((await admin.patch('/api/admin/parser/settings', { freshDays: 0 })).status).toBe(400);
// Выключенная категория не читается; срок свежести действует на блок «Топ» — его порядок ротируется.
await admin.patch('/api/admin/parser/settings', { categories: [56], freshDays: 1 });
const skipped = listing({ promoted: true });
expect(await publish(skipped)).toMatchObject({ categories: 1, foundNew: 0 });
await admin.patch('/api/admin/parser/settings', { categories: before.categories });
source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 3 * 86_400_000) });
expect(await runIngest({ db: t.db, source })).toMatchObject({ categories: 10, foundNew: 0 });
source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) });
await admin.patch('/api/admin/parser/settings', { freshDays: 3, removalCheck: false });
// Проверка снятых выключена — ничего не проверяется.
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * day) })).toEqual({ checked: 0, removed: 0 });
await admin.patch('/api/admin/parser/settings', { removalCheck: true, jinaApiKey: '' });
expect((await admin.get('/api/admin/parser/settings')).body.jinaKey).toEqual({ source: 'none', tail: null });
});
});
describe('аналитика по сотрудникам', () => {
const range = () => `from=${new Date(Date.now() - day).toISOString()}&to=${new Date(Date.now() + 3 * day).toISOString()}`;
it('воронка объекта: взято, скорость реакции, возвраты в пул, конверсия, сделки, причины отказа', async () => {
const c = await company(2);
const [a1, a2] = c.agents as [Member, Member];
const st = c.object.statuses.map((s: any) => s.id) as number[]; // new, taken, reached, meeting, contract, sale, deal, rejected
await publish(listing(), listing(), listing(), listing());
const pool = await poolOf(a1);
expect(pool).toHaveLength(4);
const [l1, l2, l3, l4] = pool.map((l) => l.id) as [number, number, number, number];
// Агент 1 берёт три лида: один доводит до сделки, один — до встречи и отказа, один теряет по таймеру.
for (const id of [l1, l2, l3]) await a1.api.post(`/api/leads/${id}/take`);
// Скорость реакции: уведомление пришло за 60, 120 и 600 секунд до «Беру» → медиана 120.
for (const [id, sec] of [[l1, 60], [l2, 120], [l3, 600]] as const) {
const [taken] = await t.db.select().from(events).where(eq(events.leadId, id)).then((r) => r.filter((e) => e.type === 'taken'));
await t.db.update(leadNotifications).set({ sentAt: new Date(taken!.at.getTime() - sec * 1000) }).where(eq(leadNotifications.leadId, id));
}
await a1.api.post(`/api/leads/${l1}/status`, { statusId: st[4] });
await a1.api.post(`/api/leads/${l1}/status`, { statusId: c.object.status('deal'), deal });
await a1.api.post(`/api/leads/${l2}/status`, { statusId: st[3] });
await a1.api.post(`/api/leads/${l2}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[4].id });
await t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - 25 * 3_600_000) }).where(eq(leads.id, l3));
expect(await runLeadJobs(t.db)).toMatchObject({ returned: 1 });
// Агент 2 подбирает потерянный лид и доводит до «Дозвонился»; руководитель назначает ему четвёртый.
await a2.api.post(`/api/leads/${l3}/take`);
await a2.api.post(`/api/leads/${l3}/status`, { statusId: st[2] });
await c.boss.api.post(`/api/leads/${l4}/assign`, { userId: a2.id });
const r = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body;
expect(r.statuses.map((s: any) => s.id)).toEqual(st);
expect(r.summary).toMatchObject({ created: 4, taken: 4, open: 0, cohort: 4 });
// Воронка целиком: все 4 взяты; до «Дозвонился» и дальше дошли 3; до встречи — 2; договор и сделка — 1; отказ — 1.
expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([4, 4, 3, 2, 1, 1, 1, 1]);
const row = (m: Member) => r.agents.find((x: any) => x.userId === m.id);
expect(row(a1)).toMatchObject({ taken: 3, reactionSec: 120, poolReturns: 1, cohort: 3, deals: 1, commission: { USD: 3000 } });
// Лид, потерянный агентом 1, у него остался на этапе «Взят»; дальнейшее — заслуга агента 2.
expect(st.map((id) => row(a1).reached[id] ?? 0)).toEqual([3, 3, 2, 2, 1, 1, 1, 1]);
expect(row(a1).reasons).toEqual({ [c.object.reasons[4].id]: 1 });
expect(row(a2)).toMatchObject({ taken: 2, poolReturns: 0, cohort: 2, deals: 0, commission: {} });
expect(st.map((id) => row(a2).reached[id] ?? 0)).toEqual([2, 2, 1, 0, 0, 0, 0, 0]);
expect(row(c.boss)).toMatchObject({ taken: 0, cohort: 0 });
expect(r.totals).toMatchObject({ taken: 5, poolReturns: 1, deals: 1, commission: { USD: 3000 } });
// Прошлый период пуст; агент аналитику не видит.
const past = (await c.boss.api.get(`/api/analytics?from=${new Date(Date.now() - 30 * day).toISOString()}&to=${new Date(Date.now() - 20 * day).toISOString()}`)).body;
expect(past.summary).toMatchObject({ created: 0, taken: 0 });
expect(past.totals.deals).toBe(0);
expect((await a1.api.get(`/api/analytics?${range()}`)).status).toBe(403);
});
it('воронка клиента: новые запросы, показы, конверсия, сделки, причины отказа', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const st = c.client.statuses.map((s: any) => s.id) as number[]; // new, qualification, selection, showing, negotiation, deal, rejected
const mk = async (name: string) => (await a.api.post('/api/clients', { name, kind: 'buyer' })).body.id as number;
const [won, lost] = [await mk('Анна'), await mk('Давид'), await mk('Мария')];
const leadId = (await a.api.post('/api/leads', { title: 'Квартира' })).body.id as number;
await a.api.post(`/api/clients/${won}/leads`, { leadId, state: 'shown' });
await a.api.post(`/api/clients/${won}/status`, { statusId: st[3] });
await a.api.post(`/api/clients/${won}/status`, { statusId: c.client.status('deal'), deal: { ...deal, commission: 2000, leadId } });
await a.api.post(`/api/clients/${lost}/status`, { statusId: st[1] });
await a.api.post(`/api/clients/${lost}/status`, { statusId: c.client.status('rejected'), rejectReasonId: c.client.reasons[0].id });
const r = (await c.boss.api.get(`/api/analytics?pipeline=client&${range()}`)).body;
expect(r.summary).toMatchObject({ created: 3, open: 1, cohort: 3 });
expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([3, 2, 1, 1, 1, 1, 1]);
const row = r.agents.find((x: any) => x.userId === a.id);
expect(row).toMatchObject({ taken: 3, showings: 1, cohort: 3, deals: 1, commission: { USD: 2000 }, reasons: { [c.client.reasons[0].id]: 1 } });
// Парная сделка одна: в аналитике объектов та же комиссия, без удвоения.
const objects = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body;
expect(objects.totals).toMatchObject({ deals: 1, commission: { USD: 2000 } });
});
});