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> @
458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
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('только для этой площадки');
|
||
});
|
||
});
|