04657bd6cd
Раньше «Ниша» стояла только на «Воронке отдела». Теперь тот же фильтр есть и на личном экране менеджера — он обзванивает подряд одну нишу, ему нужнее всех. Список ниш у менеджера считается по ЕГО карточкам: сужение «только свои» наложено раньше, поэтому чужая ниша в список не попадает, а ?rubric= не может стать лазейкой к чужой воронке — на это есть отдельная проверка. Поведение обоих экранов задаёт один кусок кода, composables/prospectRubricFilter.ts. Двумя копиями «Без ниши последним» и самосброс исчезнувшей ниши разъехались бы на первой же правке. Проверено: Pest по продажам 525 зелёных, Vitest по фронту 2077 зелёных, vue-tsc и Larastan чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
import { mount, flushPromises } from '@vue/test-utils';
|
|
import { createVuetify } from 'vuetify';
|
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
|
|
function card(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 1,
|
|
sales_user_id: 1,
|
|
stage: 'new',
|
|
source: 'search',
|
|
firm_name: 'ООО Демо',
|
|
city: 'Ростов',
|
|
phone: null,
|
|
site: null,
|
|
inn: null,
|
|
rating_label: 'горячая',
|
|
payload: {},
|
|
next_call_at: null,
|
|
reason: null,
|
|
registered_email: null,
|
|
notes: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
vi.mock('../../resources/js/api/sales', () => ({
|
|
listProspects: vi.fn().mockResolvedValue({ prospects: [], by_stage: {}, stages: [] }),
|
|
updateProspect: vi.fn().mockResolvedValue({ id: 1, stage: 'in_work' }),
|
|
createProspect: vi.fn().mockResolvedValue({ id: 2, stage: 'new', source: 'manager' }),
|
|
extractSalesErrorMessage: () => 'err',
|
|
}));
|
|
|
|
import SalesProspectsView from '../../resources/js/views/sales/SalesProspectsView.vue';
|
|
import { listProspects, updateProspect, createProspect } from '../../resources/js/api/sales';
|
|
|
|
const vuetify = createVuetify();
|
|
|
|
interface ViewVm {
|
|
open: (p: unknown) => Promise<void> | void;
|
|
createCandidate: (p: Record<string, unknown>) => Promise<void>;
|
|
applyDateFilter: (v: unknown) => Promise<void>;
|
|
}
|
|
|
|
/** Фильтр «Ниша» — тот же, что на «Воронке отдела»: экраны не должны разъезжаться. */
|
|
interface RubricVm extends ViewVm {
|
|
load: () => Promise<void>;
|
|
rubricValue: { rubric: string | null; missing: boolean } | null;
|
|
rubricItems: { value: string | null; title: string }[];
|
|
rubricNotice: string;
|
|
applyRubric: (v: string | null) => Promise<void>;
|
|
}
|
|
|
|
describe('SalesProspectsView', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(listProspects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
prospects: [card()],
|
|
by_stage: {},
|
|
stages: [],
|
|
});
|
|
});
|
|
|
|
it('загружает и показывает карточки менеджера', async () => {
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, undefined, null, null);
|
|
expect(w.text()).toContain('ООО Демо');
|
|
});
|
|
|
|
it('фильтр по датам уходит в запрос и у менеджера тоже', async () => {
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
const changed = { mode: 'changed', period: 'today', from: null, to: null };
|
|
await (w.vm as unknown as ViewVm).applyDateFilter(changed);
|
|
|
|
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, undefined, changed, null);
|
|
});
|
|
|
|
it('открытие карточки из «Новые» помечает её взятой в работу (action=opened)', async () => {
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
await (w.vm as unknown as ViewVm).open(card({ id: 7, stage: 'new' }));
|
|
await flushPromises();
|
|
|
|
expect(updateProspect).toHaveBeenCalledWith(7, { action: 'opened' });
|
|
});
|
|
|
|
it('открытие карточки НЕ из «Новые» ничего не помечает', async () => {
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
await (w.vm as unknown as ViewVm).open(card({ id: 8, stage: 'negotiation' }));
|
|
await flushPromises();
|
|
|
|
expect(updateProspect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('создаёт своего кандидата через createProspect', async () => {
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
await (w.vm as unknown as ViewVm).createCandidate({ firm_name: 'ООО Своя', city: 'Омск' });
|
|
await flushPromises();
|
|
|
|
expect(createProspect).toHaveBeenCalledWith({ firm_name: 'ООО Своя', city: 'Омск' });
|
|
});
|
|
|
|
// ── фильтр «Ниша» на личном экране (правка 04.08.2026) ────────────────────
|
|
|
|
it('фильтр «Ниша» есть и на личном экране', async () => {
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
expect(w.find('[data-testid="prospect-filter-rubric"]').exists()).toBe(true);
|
|
});
|
|
|
|
it('выбранная ниша уходит в запрос пятым доводом', async () => {
|
|
(listProspects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
prospects: [], by_stage: {}, stages: [],
|
|
rubric_facets: [{ value: 'Окна', count: 2 }, { value: 'Кровля', count: 1 }],
|
|
});
|
|
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
await (w.vm as unknown as RubricVm).applyRubric('Окна');
|
|
|
|
expect(listProspects).toHaveBeenLastCalledWith(
|
|
undefined, undefined, undefined, null, { rubric: 'Окна', missing: false },
|
|
);
|
|
});
|
|
|
|
it('пункт «Без ниши» уходит отдельным признаком', async () => {
|
|
(listProspects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
prospects: [], by_stage: {}, stages: [],
|
|
rubric_facets: [{ value: 'Окна', count: 2 }, { value: null, count: 3 }],
|
|
});
|
|
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
await (w.vm as unknown as RubricVm).applyRubric('__missing__');
|
|
|
|
expect(listProspects).toHaveBeenLastCalledWith(
|
|
undefined, undefined, undefined, null, { rubric: null, missing: true },
|
|
);
|
|
});
|
|
|
|
it('в списке ниш есть числа, «Все ниши» первым, «Без ниши» последним', async () => {
|
|
(listProspects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
prospects: [], by_stage: {}, stages: [],
|
|
rubric_facets: [{ value: 'Окна', count: 2 }, { value: null, count: 3 }],
|
|
});
|
|
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
|
|
const titles = (w.vm as unknown as RubricVm).rubricItems.map((i) => i.title);
|
|
expect(titles).toEqual(['Все ниши', 'Окна (2)', 'Без ниши (3)']);
|
|
});
|
|
|
|
it('исчезнувшая ниша сбрасывается, и портал говорит почему', async () => {
|
|
(listProspects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
prospects: [], by_stage: {}, stages: [],
|
|
rubric_facets: [{ value: 'Окна', count: 2 }],
|
|
});
|
|
|
|
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
|
await flushPromises();
|
|
const vm = w.vm as unknown as RubricVm;
|
|
|
|
await vm.applyRubric('Окна');
|
|
expect(vm.rubricValue).toEqual({ rubric: 'Окна', missing: false });
|
|
|
|
// Сузили по датам — «Окон» в новой выборке не осталось.
|
|
(listProspects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
prospects: [], by_stage: {}, stages: [],
|
|
rubric_facets: [{ value: 'Кровля', count: 1 }],
|
|
});
|
|
const changed = { mode: 'changed', period: 'today', from: null, to: null };
|
|
await vm.applyDateFilter(changed);
|
|
await flushPromises();
|
|
|
|
expect(vm.rubricValue).toBeNull();
|
|
expect(vm.rubricNotice).toContain('Окна');
|
|
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, undefined, changed, null);
|
|
});
|
|
});
|