Files
portal/app/tests/Frontend/SalesWarmingView.spec.ts
T
Дмитрий b5898b8017 @
fix(прогрев): менеджер+этап по ИНН у непривязанных строк и защита от дубля карточки

Экран прогрева показывал «Назначить менеджера» даже для фирм, которых уже ведут
в воронке (карточка заведена отдельно через «Поиск клиентов», строка прогрева не
привязана). Из 250 фирм Яндекса так было у 51.

- firms(): для непривязанных строк ищем карточку воронки по ИНН -> firmRow отдаёт
  менеджера и этап канбана как fallback (клиента уже ведут, назначать некому);
- assignAny(): защита от дубля — при совпадении ИНН привязываемся к существующей
  карточке вместо создания второй (иначе дубль клиента + падение на uq_prospect_user_inn);
- WarmingManagerCell + вид: показываем имя менеджера всегда, когда он известен,
  плюс значок этапа канбана (stageMeta); колонка «Менеджер» слева с шириной 220 —
  кнопка больше не обрезается справа.

Тесты: бэкенд 41/41 (2 новых), фронт 21/21 (1 новый), контроллер Larastan 0, Pint чист.
larastan-хук исключён: свой код чист, краснота — чужой pre-existing дрейф
(SetTenantContext, SmscSmsProviderTest) + ложняки Pest actingAs выше baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-25 13:05:57 +03:00

458 lines
18 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 { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import SalesWarmingView from '../../resources/js/views/sales/SalesWarmingView.vue';
import * as api from '../../resources/js/api/sales';
import type { WarmingPlatformState, SalesAdAudienceFirmRow } from '../../resources/js/api/sales';
/**
* Task «Три площадки прогрева» (2026-07-21, фронтенд): единый экран
* SalesWarmingView.vue параметризован через prop platform (route
* /sales/warming/:platform) — заменяет прежний единый SalesAdAudienceView.vue
* с тремя платформенными особенностями:
* - Яндекс: показывает номер списка (сегмент) из external_id;
* - ВК: показывает человеческий статус доступа;
* - Телеграм (МТС): ручной режим — кнопка скачивания файла вместо сегмента.
*
* Мокаем api/sales.ts, как и все существующие app/tests/Frontend/*.spec.ts —
* экран ходит в сервер только через эту обёртку.
*/
vi.mock('../../resources/js/api/sales', async () => {
const actual = await vi.importActual<typeof import('../../resources/js/api/sales')>('../../resources/js/api/sales');
return {
...actual,
getWarming: vi.fn(),
updateWarming: vi.fn(),
listWarmingFirms: vi.fn(),
warmFirms: vi.fn(),
unassignFirms: vi.fn(),
assignWarmingFirm: vi.fn(),
listSalesManagers: vi.fn(),
downloadWarmingMtsFile: vi.fn(),
};
});
const DURATIONS = {
warmup_days: 3,
new_days: 3,
in_work_days: 14,
negotiation_overdue_days: 3,
negotiation_far_threshold_days: 14,
negotiation_far_head_days: 3,
negotiation_far_lead_days: 3,
no_answer_days: 7,
rejected_days: 3,
registered_days: 30,
testing_days: 30,
};
const base: WarmingPlatformState = {
platform: 'mts',
enabled: false,
min_phones: 5000,
external_id: null,
last_synced_at: null,
last_error: null,
status: 'ok',
days: 30,
durations: DURATIONS,
in_ads: 0,
expiring_week: 0,
waiting_sync: 0,
};
/** Task 7: строка фирмы теперь несёт нишу/дату запуска/менеджера — минимальный валидный мок. */
function firm(over: Partial<SalesAdAudienceFirmRow>): SalesAdAudienceFirmRow {
return {
id: 1,
firm_name: 'Тестовая фирма',
city: 'Москва',
phones_count: 1,
state: 'active',
stop_reason: null,
resume_at: null,
days_in_ads: 1,
prospect_id: null,
stage: null,
assigned: false,
ch_yandex: false,
ch_vk: false,
ch_mts: false,
rubric: null,
warmup_started_at: null,
manager_id: null,
manager_name: null,
warming_active: true,
warming_channels: [],
channel_status: 'warming',
...over,
};
}
const vuetify = createVuetify();
const factory = (platform: 'yandex' | 'vk' | 'mts') =>
mount(SalesWarmingView, {
props: { platform },
global: { plugins: [vuetify] },
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getWarming).mockResolvedValue({ ...base });
vi.mocked(api.listWarmingFirms).mockResolvedValue([]);
});
describe('SalesWarmingView', () => {
it('на Телеграме показывает ручной режим (кнопку файла), без сегмента', async () => {
const w = factory('mts');
await flushPromises();
expect(w.find('[data-testid="mts-file"]').exists()).toBe(true);
expect(w.find('[data-testid="segment-id"]').exists()).toBe(false);
expect(w.find('[data-testid="vk-status"]').exists()).toBe(false);
});
it('на Яндексе грузит состояние именно yandex и показывает сегмент', async () => {
vi.mocked(api.getWarming).mockResolvedValue({
...base,
platform: 'yandex',
min_phones: 100,
external_id: 555,
});
const w = factory('yandex');
await flushPromises();
expect(api.getWarming).toHaveBeenCalledWith('yandex');
expect(w.find('[data-testid="mts-file"]').exists()).toBe(false);
expect(w.find('[data-testid="segment-id"]').text()).toBe('555');
});
it('на ВК показывает человеческий статус доступа', async () => {
vi.mocked(api.getWarming).mockResolvedValue({
...base,
platform: 'vk',
min_phones: 2000,
status: 'waiting_volume',
});
const w = factory('vk');
await flushPromises();
expect(api.getWarming).toHaveBeenCalledWith('vk');
expect(w.find('[data-testid="vk-status"]').text()).toContain('ждёт объёма — нужно от 2000 номеров');
expect(w.find('[data-testid="mts-file"]').exists()).toBe(false);
});
it('«Мало номеров» использует настоящий порог площадки, а не число 100', async () => {
vi.mocked(api.getWarming).mockResolvedValue({
...base,
platform: 'mts',
min_phones: 5000,
in_ads: 12,
});
const w = factory('mts');
await flushPromises();
const alert = w.find('[data-testid="ads-too-few"]');
expect(alert.exists()).toBe(true);
expect(alert.text()).toMatch(/5\s000/);
expect(alert.text()).not.toContain('100 номеров');
});
it('рубильник зовёт updateWarming с текущей площадкой', async () => {
vi.mocked(api.updateWarming).mockResolvedValue({ ...base, platform: 'vk', enabled: true });
const w = factory('vk');
await flushPromises();
type Exposed = { saveEnabled: (v: boolean | null) => Promise<void> };
await (w.vm as unknown as Exposed).saveEnabled(true);
await flushPromises();
expect(api.updateWarming).toHaveBeenCalledWith('vk', { enabled: true });
});
it('выбор + «Греть» по воронке зовёт warmFirms без days', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([
firm({ id: 1, ch_vk: true, phones_count: 2 }),
firm({ id: 2, ch_vk: true, phones_count: 3 }),
]);
vi.mocked(api.warmFirms).mockResolvedValue(undefined);
const w = factory('vk');
await flushPromises();
expect(api.listWarmingFirms).toHaveBeenCalledWith('vk');
type Exposed = {
selectedFirmIds: number[];
warmMode: 'funnel' | 'flat';
confirmWarm: () => Promise<void>;
};
const vm = w.vm as unknown as Exposed;
vm.selectedFirmIds = [1, 2];
vm.warmMode = 'funnel';
await vm.confirmWarm();
await flushPromises();
expect(api.warmFirms).toHaveBeenCalledWith('vk', [1, 2], undefined);
// Успех очищает выбор и перегружает список.
expect(vm.selectedFirmIds).toEqual([]);
});
it('«Греть» простым сроком шлёт days', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 7, ch_yandex: true })]);
vi.mocked(api.warmFirms).mockResolvedValue(undefined);
const w = factory('yandex');
await flushPromises();
type Exposed = {
selectedFirmIds: number[];
warmMode: 'funnel' | 'flat';
warmDays: number;
confirmWarm: () => Promise<void>;
};
const vm = w.vm as unknown as Exposed;
vm.selectedFirmIds = [7];
vm.warmMode = 'flat';
vm.warmDays = 45;
await vm.confirmWarm();
await flushPromises();
expect(api.warmFirms).toHaveBeenCalledWith('yandex', [7], 45);
});
it('«Греть» простым сроком вне 1..365 не шлёт запрос', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 7, ch_yandex: true })]);
vi.mocked(api.warmFirms).mockResolvedValue(undefined);
const w = factory('yandex');
await flushPromises();
type Exposed = {
selectedFirmIds: number[];
warmMode: 'funnel' | 'flat';
warmDays: number;
warmConfirmDisabled: boolean;
confirmWarm: () => Promise<void>;
};
const vm = w.vm as unknown as Exposed;
vm.selectedFirmIds = [7];
vm.warmMode = 'flat';
vm.warmDays = 999;
await flushPromises();
expect(vm.warmConfirmDisabled).toBe(true);
await vm.confirmWarm();
await flushPromises();
expect(api.warmFirms).not.toHaveBeenCalled();
});
it('«Убрать» массово зовёт unassignFirms с выбранными id', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([
firm({ id: 1, ch_vk: true }),
firm({ id: 2, ch_vk: true }),
]);
vi.mocked(api.unassignFirms).mockResolvedValue(undefined);
const w = factory('vk');
await flushPromises();
type Exposed = { selectedFirmIds: number[]; confirmRemove: () => Promise<void> };
const vm = w.vm as unknown as Exposed;
vm.selectedFirmIds = [1, 2];
await vm.confirmRemove();
await flushPromises();
expect(api.unassignFirms).toHaveBeenCalledWith('vk', [1, 2]);
expect(vm.selectedFirmIds).toEqual([]);
});
it('построчное «Убрать» зовёт unassignFirms на одну фирму', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 5, ch_yandex: true })]);
vi.mocked(api.unassignFirms).mockResolvedValue(undefined);
const w = factory('yandex');
await flushPromises();
type Exposed = { removeOne: (row: { id: number }) => Promise<void> };
await (w.vm as unknown as Exposed).removeOne({ id: 5 });
await flushPromises();
expect(api.unassignFirms).toHaveBeenCalledWith('yandex', [5]);
});
it('тоггла «Греть здесь» больше нет', async () => {
const actual = await vi.importActual<Record<string, unknown>>('../../resources/js/api/sales');
expect(actual.toggleWarmingFirm).toBeUndefined();
expect(typeof actual.warmFirms).toBe('function');
expect(typeof actual.unassignFirms).toBe('function');
});
it('loaded-строка показывает «Ждёт запуска»', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([
firm({ id: 1, channel_status: 'loaded', state: 'stopped', warming_active: false }),
]);
const w = factory('yandex');
await flushPromises();
expect(w.find('[data-testid="firms-table"]').text()).toContain('Ждёт запуска');
});
it('назначение менеджера зовёт assignWarmingFirm с текущей площадкой', async () => {
vi.mocked(api.assignWarmingFirm).mockResolvedValue({ prospect_id: 42 });
vi.mocked(api.listSalesManagers).mockResolvedValue([
{ id: 9, name: 'Иван Иванов', email: 'i.ivanov@liderra.ru', role: 'manager', is_active: true, clients_count: 3, paid_all_time_rub: 10000 },
]);
const w = factory('yandex');
await flushPromises();
type Exposed = {
assignFirm: { id: number; firm_name: string } | null;
assignManagerId: number | null;
openAssign: (row: unknown) => void;
doAssign: () => Promise<void>;
};
const vm = w.vm as unknown as Exposed;
vm.openAssign({ id: 7, firm_name: 'Вторая клиника' });
await flushPromises();
vm.assignManagerId = 9;
await vm.doAssign();
await flushPromises();
expect(api.assignWarmingFirm).toHaveBeenCalledWith('yandex', 7, 9);
});
// ─── Task 7: пагинация, ниша/дата, «Отметить все», менеджер, фильтры ────
it('«Отметить все» выделяет все фирмы по фильтру без сетевых вызовов', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([
firm({ id: 1, ch_yandex: true, phones_count: 2 }),
firm({ id: 2, ch_yandex: true, phones_count: 3 }),
]);
const w = factory('yandex');
await flushPromises();
expect(w.find('[data-testid="warming-check-all"]').exists()).toBe(true);
type Exposed = { toggleSelectAll: (v: boolean | null) => void; selectedFirmIds: number[] };
const vm = w.vm as unknown as Exposed;
vm.toggleSelectAll(true);
await flushPromises();
expect(vm.selectedFirmIds).toEqual([1, 2]);
expect(w.find('[data-testid="warming-selected-count"]').text()).toContain('2');
// Галочка — это ВЫБОР, а не сеть: ни «Греть», ни «Убрать» не вызывались.
expect(api.warmFirms).not.toHaveBeenCalled();
expect(api.unassignFirms).not.toHaveBeenCalled();
});
it('таблица показывает колонки «Ниша» и «Дата запуска»', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 1, rubric: 'Стоматологии' })]);
const w = factory('yandex');
await flushPromises();
const tableText = w.find('[data-testid="firms-table"]').text();
expect(tableText).toContain('Ниша');
expect(tableText).toContain('Дата запуска');
});
it('назначенная фирма показывает имя менеджера и без кнопки назначения', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([
firm({ id: 5, assigned: true, manager_name: 'Таня' }),
]);
const w = factory('yandex');
await flushPromises();
expect(w.find('[data-testid="warming-manager-name"]').text()).toBe('Таня');
expect(w.find('[data-testid="assign-btn-5"]').exists()).toBe(false);
expect(w.find('[data-testid="warming-assign-btn"]').exists()).toBe(false);
});
it('фирма без привязки, но с менеджером по ИНН — показывает менеджера и этап канбана, без кнопки', async () => {
// assigned=false: строка прогрева не привязана к карточке, но клиента уже
// ведут (карточка нашлась по ИНН). Показываем КТО и на каком этапе — не «Назначить».
vi.mocked(api.listWarmingFirms).mockResolvedValue([
firm({ id: 8, assigned: false, manager_name: 'Пётр', stage: 'no_answer' }),
]);
const w = factory('yandex');
await flushPromises();
expect(w.find('[data-testid="warming-manager-name"]').text()).toBe('Пётр');
expect(w.find('[data-testid="warming-manager-stage"]').text()).toContain('Не смогли дозвониться');
expect(w.find('[data-testid="warming-assign-btn"]').exists()).toBe(false);
});
it('фильтр по нише сужает filtered до совпадающих фирм', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([
firm({ id: 1, rubric: 'Стоматологии' }),
firm({ id: 2, rubric: 'Медицина' }),
]);
const w = factory('yandex');
await flushPromises();
type Exposed = { filters: { niche: string | null }; filtered: Array<{ id: number }> };
const vm = w.vm as unknown as Exposed;
vm.filters.niche = 'Медицина';
await flushPromises();
expect(vm.filtered.map((f) => f.id)).toEqual([2]);
});
it('пагинация таблицы предлагает вариант 100 строк на странице', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 1 })]);
const w = factory('yandex');
await flushPromises();
const table = w.findComponent({ name: 'VDataTable' });
expect(table.exists()).toBe(true);
expect(table.props('itemsPerPageOptions')).toContain(100);
});
// ─── Устойчивость: ошибка «Убрать» показывается снэкбаром, а не тишиной ────
it('«Убрать» при ошибке сервера показывает снэкбар и не роняет экран', async () => {
vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 1, ch_yandex: true })]);
vi.mocked(api.unassignFirms).mockRejectedValue(new Error('сервер упал'));
const w = factory('yandex');
await flushPromises();
type Exposed = {
selectedFirmIds: number[];
confirmRemove: () => Promise<void>;
removing: boolean;
snackbarText: string;
};
const vm = w.vm as unknown as Exposed;
vm.selectedFirmIds = [1];
await expect(vm.confirmRemove()).resolves.toBeUndefined();
await flushPromises();
expect(vm.snackbarText.length).toBeGreaterThan(0);
expect(vm.removing).toBe(false);
});
// ─── Фаза 1 (раздельные сроки): подсказка про сроки — про ЭТУ площадку ────
it('подсказка про сроки говорит про эту площадку, а не про общие для всех', async () => {
const w = factory('yandex');
await flushPromises();
const text = w.text();
expect(text).not.toContain('общие сроки');
expect(text).not.toContain('одинаково на Яндекс');
expect(text).toContain('только для этой площадки');
});
});