Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -0,0 +1,460 @@
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user