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>; let tgId = 9000; let extId = 2_000_000; const pages = new Map(); /** Что «ответит» list.am на проверку объявления: false — снято, undefined — понять не удалось. */ const alive = new Map(); 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 => { 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; 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 => { 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 } }); }); });