1e0442facf
feat(sales): результат разговора и его содержание — одной записью в журнале Владелец: «объедини в одну запись: договорились… сверху, внизу текст». Было две записи подряд — в ленте читалось как два события, хотя разговор один. - БД v8.75: sales_prospect_notes.title VARCHAR(500) NULL — что решили. - Одна запись kind=note: title = «Договорились на созвон 20.07.2026 17:35», body = краткое содержание. Есть результат без содержания — как раньше, одна запись kind=stage без заголовка. Ручная заметка — без заголовка. - В ленте заголовок строкой сверху, под ним текст. - Старые парные записи задним числом не сливаем — историю не переписываем. Спека §20.2. Гейты: Pest 240/240 sales, Vitest 64/64 воронка, vue-tsc чисто, Larastan 0 в своих файлах. TDD RED→GREEN. 🪤 Тесты DOM для v-dialog: контент уезжает телепортом в body — искать через document.body.querySelector, а не wrapper.find. LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
412 lines
19 KiB
TypeScript
412 lines
19 KiB
TypeScript
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||
|
||
vi.mock('../../resources/js/api/sales', () => ({
|
||
updateProspectContacts: vi.fn().mockResolvedValue({}),
|
||
listProspectNotes: vi.fn().mockResolvedValue([]),
|
||
addProspectNote: vi.fn(),
|
||
}));
|
||
import SalesProspectDialog from '../../resources/js/components/sales/SalesProspectDialog.vue';
|
||
import { updateProspectContacts, listProspectNotes, addProspectNote } from '../../resources/js/api/sales';
|
||
import type { SalesProspect } from '../../resources/js/api/sales';
|
||
|
||
const vuetify = createVuetify();
|
||
|
||
function prospect(overrides: Partial<SalesProspect> = {}): SalesProspect {
|
||
return {
|
||
id: 1,
|
||
sales_user_id: 1,
|
||
stage: 'new',
|
||
firm_name: 'ООО Тест',
|
||
city: 'Ростов',
|
||
phone: '+7800',
|
||
site: 'test.ru',
|
||
inn: '6161',
|
||
rating_label: 'горячая',
|
||
source: 'search',
|
||
legal_name: null,
|
||
contacts: [],
|
||
payload: {},
|
||
next_call_at: null,
|
||
reason: null,
|
||
registered_email: null,
|
||
notes: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
interface DialogVm {
|
||
action: string;
|
||
reason: string;
|
||
nextCallAt: string;
|
||
email: string;
|
||
siteHref: string | null;
|
||
availableActions: { value: string; title: string }[];
|
||
infoRows: { label: string; value: string; href?: string }[];
|
||
contactRows: { name: string; position: string; phones: string[] }[];
|
||
notes: { id: number; kind: string; title: string | null; body: string; author: string | null; created_at: string }[];
|
||
summary: string;
|
||
submit: () => Promise<void>;
|
||
error: string;
|
||
}
|
||
|
||
function factory(p: SalesProspect) {
|
||
return mount(SalesProspectDialog, {
|
||
props: { modelValue: true, prospect: p },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
}
|
||
|
||
describe('SalesProspectDialog', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
document.body.innerHTML = '';
|
||
});
|
||
|
||
it('siteHref добавляет https к домену', () => {
|
||
const w = factory(prospect());
|
||
expect((w.vm as unknown as DialogVm).siteHref).toBe('https://test.ru');
|
||
});
|
||
|
||
it('для stage=user действие «Отказ» недоступно', () => {
|
||
const w = factory(prospect({ stage: 'user' }));
|
||
const values = (w.vm as unknown as DialogVm).availableActions.map((a) => a.value);
|
||
expect(values).not.toContain('rejected');
|
||
});
|
||
|
||
it('для обычной стадии «Отказ» доступен', () => {
|
||
const w = factory(prospect({ stage: 'negotiation' }));
|
||
const values = (w.vm as unknown as DialogVm).availableActions.map((a) => a.value);
|
||
expect(values).toContain('rejected');
|
||
});
|
||
|
||
it('«Переговоры» без даты — submit не эмитит save', () => {
|
||
const w = factory(prospect());
|
||
(w.vm as unknown as DialogVm).action = 'negotiation';
|
||
(w.vm as unknown as DialogVm).submit();
|
||
expect(w.emitted('save')).toBeFalsy();
|
||
});
|
||
|
||
it('«Отказ» с причиной — эмитит save с action=rejected', async () => {
|
||
const w = factory(prospect({ stage: 'negotiation' }));
|
||
(w.vm as unknown as DialogVm).action = 'rejected';
|
||
(w.vm as unknown as DialogVm).reason = 'дорого';
|
||
await (w.vm as unknown as DialogVm).submit();
|
||
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'rejected', reason: 'дорого' });
|
||
});
|
||
|
||
it('infoRows показывает ВСЕ данные поиска из payload', () => {
|
||
const w = factory(
|
||
prospect({
|
||
inn: '6161000000',
|
||
payload: {
|
||
legal_name: 'ООО «Тест»',
|
||
ogrn: '1234567890123',
|
||
address: 'г. Ростов, ул. Ленина 1',
|
||
legal_status: 'действующая',
|
||
director: 'Иванов Иван Иванович',
|
||
director_post: 'директор',
|
||
director_is_ip: false,
|
||
director_inn: '616100000000',
|
||
contact_phone: '+79001234567',
|
||
contact_email: 'ivan@test.ru',
|
||
direct_budget_min: 80000,
|
||
direct_budget: 250000,
|
||
direct_keys: 1200,
|
||
direct_ads: 30,
|
||
channels: ['Яндекс.Директ', 'коллтрекинг'],
|
||
},
|
||
}),
|
||
);
|
||
const rows = (w.vm as unknown as DialogVm).infoRows;
|
||
const text = rows.map((r) => `${r.label}: ${r.value}`).join(' | ');
|
||
|
||
// Показываем — из девятки владельца (§18)
|
||
expect(text).toContain('Иванов Иван Иванович'); // директор
|
||
expect(text).toContain('директор'); // должность
|
||
expect(text).toContain('+79001234567'); // тел. директора (предполагаемый)
|
||
expect(text).toContain('коллтрекинг'); // каналы
|
||
expect(text).toContain('6161000000'); // ИНН фирмы
|
||
expect(text).toContain('г. Ростов, ул. Ленина 1'); // адрес
|
||
expect(text).toMatch(/80 000.*250 000/); // вилка бюджета
|
||
|
||
// НЕ показываем — вне девятки
|
||
expect(text).not.toContain('616100000000'); // личный ИНН руководителя (ПДн)
|
||
expect(text).not.toContain('ivan@test.ru'); // почта директора
|
||
expect(text).not.toContain('1234567890123'); // ОГРН
|
||
expect(text).not.toContain('1 200'); // запросы в Директе
|
||
});
|
||
|
||
it('«Зарегистрировался» доступен на обычной стадии, скрыт на user', () => {
|
||
const a = (w: ReturnType<typeof factory>) => (w.vm as unknown as DialogVm).availableActions.map((x) => x.value);
|
||
expect(a(factory(prospect({ stage: 'negotiation' })))).toContain('registered');
|
||
expect(a(factory(prospect({ stage: 'user' })))).not.toContain('registered');
|
||
expect(a(factory(prospect({ stage: 'testing' })))).not.toContain('registered');
|
||
});
|
||
|
||
it('«Зарегистрировался» без e-mail — save не эмитится; с e-mail — эмитит {action:registered,email}', async () => {
|
||
const w = factory(prospect({ stage: 'negotiation' }));
|
||
(w.vm as unknown as DialogVm).action = 'registered';
|
||
await (w.vm as unknown as DialogVm).submit();
|
||
expect(w.emitted('save')).toBeFalsy();
|
||
|
||
(w.vm as unknown as DialogVm).email = 'client@example.com';
|
||
await (w.vm as unknown as DialogVm).submit();
|
||
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'registered', email: 'client@example.com' });
|
||
});
|
||
|
||
it('«Вернуть в Новые» есть только на стадии «Взят в работу» и эмитит back_to_new', async () => {
|
||
const inWork = factory(prospect({ stage: 'in_work' }));
|
||
const vm = inWork.vm as unknown as DialogVm & { canReturnToNew: boolean; returnToNew: () => void };
|
||
expect(vm.canReturnToNew).toBe(true);
|
||
vm.returnToNew();
|
||
expect(inWork.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'back_to_new' });
|
||
|
||
const other = factory(prospect({ stage: 'negotiation' }));
|
||
expect((other.vm as unknown as { canReturnToNew: boolean }).canReturnToNew).toBe(false);
|
||
});
|
||
|
||
it('infoRows пропускает пустые поля payload', () => {
|
||
const w = factory(prospect({ payload: {}, inn: null }));
|
||
const labels = (w.vm as unknown as DialogVm).infoRows.map((r) => r.label);
|
||
expect(labels).not.toContain('ОГРН');
|
||
expect(labels).not.toContain('Директор');
|
||
});
|
||
|
||
it('юрлицо из карточки показывается в списке данных', () => {
|
||
const w = factory(prospect({ legal_name: 'ООО «Улыбка Плюс»' }));
|
||
const rows = (w.vm as unknown as DialogVm).infoRows;
|
||
expect(rows.find((r) => r.label === 'Юрлицо')?.value).toBe('ООО «Улыбка Плюс»');
|
||
});
|
||
|
||
it('юрлицо из карточки важнее старого значения из поиска', () => {
|
||
const w = factory(
|
||
prospect({ legal_name: 'ООО «Из карточки»', payload: { legal_name: 'ООО «Из поиска»' } }),
|
||
);
|
||
const rows = (w.vm as unknown as DialogVm).infoRows;
|
||
expect(rows.find((r) => r.label === 'Юрлицо')?.value).toBe('ООО «Из карточки»');
|
||
});
|
||
|
||
it('показывает ТОЛЬКО девять полей владельца, в его порядке', () => {
|
||
const w = factory(
|
||
prospect({
|
||
legal_name: 'ООО "ОМДЕНТ"',
|
||
site: 'omdent.ru',
|
||
inn: '5501261235',
|
||
phone: '+73812244222',
|
||
payload: {
|
||
address: 'г Омск, ул Химиков, д 34',
|
||
channels: ['Яндекс.Директ', 'VK-myTarget (пиксель)'],
|
||
direct_budget_min: 10415,
|
||
direct_budget: 23656,
|
||
director: 'Захаров Александр Владимирович',
|
||
director_post: 'Генеральный директор',
|
||
contact_phone: '9136518088, 9133343217',
|
||
// лишнее из поиска — показывать НЕ должны
|
||
ogrn: '1145543043095',
|
||
legal_status: 'действующая',
|
||
director_inn: '550405815600',
|
||
contact_email: 'zav5700@mail.ru',
|
||
confidence: 'высокая',
|
||
advertises: 'да',
|
||
direct_keys: 8,
|
||
},
|
||
}),
|
||
);
|
||
const labels = (w.vm as unknown as DialogVm).infoRows.map((r) => r.label);
|
||
|
||
expect(labels).toEqual([
|
||
'Юрлицо',
|
||
'Сайт',
|
||
'ИНН фирмы',
|
||
'Адрес',
|
||
'Телефон общий',
|
||
'Каналы',
|
||
'Бюджет',
|
||
'Директор',
|
||
'Тел. директора (предполагаемый)',
|
||
]);
|
||
});
|
||
|
||
it('оценка, ОГРН, почта директора и прочее больше не показываются', () => {
|
||
const w = factory(
|
||
prospect({ rating_label: 'горячая', payload: { ogrn: '114', contact_email: 'z@m.ru' } }),
|
||
);
|
||
const labels = (w.vm as unknown as DialogVm).infoRows.map((r) => r.label);
|
||
expect(labels).not.toContain('Оценка');
|
||
expect(labels).not.toContain('ОГРН');
|
||
expect(labels).not.toContain('Почта директора');
|
||
expect(labels).not.toContain('Достоверность');
|
||
});
|
||
|
||
it('пустые поля не показываются', () => {
|
||
const w = factory(prospect({ legal_name: null, site: null, inn: null, phone: null, payload: {} }));
|
||
expect((w.vm as unknown as DialogVm).infoRows).toEqual([]);
|
||
});
|
||
|
||
it('директор с должностью склеивается в одну строку', () => {
|
||
const w = factory(
|
||
prospect({ payload: { director: 'Захаров А. В.', director_post: 'Генеральный директор' } }),
|
||
);
|
||
const rows = (w.vm as unknown as DialogVm).infoRows;
|
||
expect(rows.find((r) => r.label === 'Директор')?.value).toBe('Захаров А. В. — Генеральный директор');
|
||
});
|
||
|
||
it('бюджет показывается вилкой', () => {
|
||
const w = factory(prospect({ payload: { direct_budget_min: 10415, direct_budget: 23656 } }));
|
||
const rows = (w.vm as unknown as DialogVm).infoRows;
|
||
expect(rows.find((r) => r.label === 'Бюджет')?.value).toBe('10 415 – 23 656 ₽/мес');
|
||
});
|
||
|
||
it('контакты карточки разворачиваются в форму для правки', () => {
|
||
const w = factory(
|
||
prospect({ contacts: [{ name: 'Захаров', position: 'директор', phones: ['+79136518088'] }] }),
|
||
);
|
||
const rows = (w.vm as unknown as DialogVm).contactRows;
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]).toEqual({ name: 'Захаров', position: 'директор', phones: ['+79136518088'] });
|
||
});
|
||
|
||
it('без контактов даёт одну пустую строку — есть куда записать после обзвона', () => {
|
||
const w = factory(prospect());
|
||
const rows = (w.vm as unknown as DialogVm).contactRows;
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0].name).toBe('');
|
||
});
|
||
|
||
it('журнал подгружается при открытии карточки', async () => {
|
||
(listProspectNotes as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||
{ id: 2, kind: 'note', title: null, body: 'СВЕЖАЯ', author: 'Таня', created_at: '2026-07-18T10:00:00+03:00' },
|
||
{ id: 1, kind: 'stage', title: null, body: 'Взял в работу', author: 'Таня', created_at: '2026-07-17T10:00:00+03:00' },
|
||
]);
|
||
const w = factory(prospect({ id: 7 }));
|
||
await flushPromises();
|
||
|
||
expect(listProspectNotes).toHaveBeenCalledWith(7);
|
||
expect((w.vm as unknown as DialogVm).notes).toHaveLength(2);
|
||
expect((w.vm as unknown as DialogVm).notes[0].body).toBe('СВЕЖАЯ');
|
||
});
|
||
|
||
it('краткое содержание уходит вместе с результатом разговора', async () => {
|
||
const w = factory(prospect({ stage: 'in_work' }));
|
||
const vm = w.vm as unknown as DialogVm;
|
||
vm.action = 'negotiation';
|
||
vm.nextCallAt = '2026-07-20T10:30';
|
||
vm.summary = 'Директор просил перезвонить с ценами';
|
||
await vm.submit();
|
||
|
||
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({
|
||
action: 'negotiation',
|
||
next_call_at: '2026-07-20T10:30',
|
||
summary: 'Директор просил перезвонить с ценами',
|
||
});
|
||
});
|
||
|
||
it('недозвон отдаёт причину и дату следующего перезвона', async () => {
|
||
const w = factory(prospect({ stage: 'in_work' }));
|
||
const vm = w.vm as unknown as DialogVm;
|
||
vm.action = 'no_answer';
|
||
vm.reason = 'не берёт трубку';
|
||
vm.nextCallAt = '2026-07-21T09:00';
|
||
await vm.submit();
|
||
|
||
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({
|
||
action: 'no_answer',
|
||
reason: 'не берёт трубку',
|
||
next_call_at: '2026-07-21T09:00',
|
||
});
|
||
});
|
||
|
||
it('недозвон без даты перезвона тоже сохраняется', async () => {
|
||
const w = factory(prospect({ stage: 'in_work' }));
|
||
const vm = w.vm as unknown as DialogVm;
|
||
vm.action = 'no_answer';
|
||
vm.reason = 'занято';
|
||
await vm.submit();
|
||
|
||
const payload = w.emitted('save')?.[0]?.[0] as Record<string, unknown>;
|
||
expect(payload.action).toBe('no_answer');
|
||
expect(payload.next_call_at ?? null).toBeNull();
|
||
});
|
||
it('одна кнопка «Сохранить»: контакты уходят вместе с результатом', async () => {
|
||
const w = factory(prospect({ id: 42, stage: 'in_work' }));
|
||
const vm = w.vm as unknown as DialogVm;
|
||
vm.contactRows = [
|
||
{ name: 'Денчик', position: 'Дир', phones: ['9990000009', ''] },
|
||
{ name: '', position: '', phones: [''] },
|
||
];
|
||
vm.action = 'negotiation';
|
||
vm.nextCallAt = '2026-07-20T17:35';
|
||
vm.summary = 'Сказал наберёт меня маркетолог';
|
||
await vm.submit();
|
||
await flushPromises();
|
||
|
||
expect(updateProspectContacts).toHaveBeenCalledWith(42, [
|
||
{ name: 'Денчик', position: 'Дир', phones: ['9990000009'] },
|
||
]);
|
||
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'negotiation' });
|
||
});
|
||
|
||
it('«Сохранить» без результата сохраняет контакты и закрывает окно', async () => {
|
||
const w = factory(prospect({ id: 42 }));
|
||
const vm = w.vm as unknown as DialogVm;
|
||
vm.contactRows = [{ name: 'Денчик', position: '', phones: ['9990000009'] }];
|
||
await vm.submit();
|
||
await flushPromises();
|
||
|
||
expect(updateProspectContacts).toHaveBeenCalled();
|
||
expect(w.emitted('save')).toBeFalsy();
|
||
expect(w.emitted('update:modelValue')?.[0]?.[0]).toBe(false);
|
||
});
|
||
|
||
it('контакты не сохранились → результат не отправляем, показываем ошибку', async () => {
|
||
(updateProspectContacts as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('нет сети'));
|
||
const w = factory(prospect({ id: 42, stage: 'in_work' }));
|
||
const vm = w.vm as unknown as DialogVm;
|
||
vm.action = 'no_answer';
|
||
vm.reason = 'занято';
|
||
await vm.submit();
|
||
await flushPromises();
|
||
|
||
expect(w.emitted('save')).toBeFalsy();
|
||
expect(vm.error).not.toBe('');
|
||
});
|
||
|
||
it('отдельной кнопки «Сохранить контакты» больше нет', () => {
|
||
const w = factory(prospect());
|
||
expect(w.text()).not.toContain('Сохранить контакты');
|
||
});
|
||
it('запись с заголовком: сверху что решили, снизу текст разговора', async () => {
|
||
(listProspectNotes as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||
{
|
||
id: 3,
|
||
kind: 'note',
|
||
title: 'Договорились на созвон 20.07.2026 17:35',
|
||
body: 'Сказал наберёт меня маркетолог',
|
||
author: 'Таня Михалева',
|
||
created_at: '2026-07-18T17:39:00+03:00',
|
||
},
|
||
]);
|
||
const w = factory(prospect({ id: 7 }));
|
||
await flushPromises();
|
||
|
||
const item = document.body.querySelector('.prospect-log-item');
|
||
expect(item?.querySelector('.prospect-log-title')?.textContent?.trim())
|
||
.toBe('Договорились на созвон 20.07.2026 17:35');
|
||
expect(item?.querySelector('.prospect-log-body')?.textContent?.trim())
|
||
.toBe('Сказал наберёт меня маркетолог');
|
||
});
|
||
|
||
it('запись без заголовка рисуется без пустой строки сверху', async () => {
|
||
(listProspectNotes as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||
{ id: 1, kind: 'stage', title: null, body: 'Взял в работу', author: 'Таня', created_at: '2026-07-18T10:00:00+03:00' },
|
||
]);
|
||
const w = factory(prospect({ id: 7 }));
|
||
await flushPromises();
|
||
|
||
expect(document.body.querySelector('.prospect-log-title')).toBeNull();
|
||
expect(document.body.querySelector('.prospect-log-body')?.textContent?.trim()).toBe('Взял в работу');
|
||
});
|
||
});
|