Files
portal/app/tests/Frontend/FieldWorkspaceDuplicates.spec.ts
T
Дмитрий 7d6516e6b2 feat(autopodbor): журнал слияний + возврат поглощённой карточки
Слияние конкурентов больше не «чёрная дыра» (баг «САПС/Метрополис»: карточка
исчезла без следа, вернуть было нечем). Теперь каждое слияние пишет событие в
autopodbor_merge_events со СНИМКОМ поглощённых карточек (имя/сайт/телефоны/
справочники), кто и когда слил.

- Таблица autopodbor_merge_events (+RLS tenant_isolation, паттерн 1:1 с sources,
  rls-ревью OK; nullOnDelete на survivor_id/user_id — история не рушится).
- AutopodborCompetitorMerger::merge($tenantId,$ids,$name,$userId) пишет событие.
- GET /field/merge-events — история; POST /field/merge-events/{id}/restore —
  возврат поглощённых как предложения (защита от повторного возврата).
- Фронт: панель «🕘 История слияний» + «↩ Вернуть карточку».

Бэк 428/428, фронт 12/12, Pint чистый, build ок. schema.sql канон-синк всех
таблиц автоподбора — предвыкатный TODO (вся фича migration-only в этой ветке).

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

276 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field') };
}
function mountWs(nav: any) {
return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
function baseStore() {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockResolvedValue();
vi.spyOn(store, 'loadProposalGroups').mockResolvedValue();
return store;
}
describe('FieldWorkspaceScreen — финальный проход «Почистить поле»', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('открытие окна грузит группы-кандидаты и показывает имена + причину', async () => {
const store = baseStore();
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{ reason: 'совпал телефон: 79135396546', competitors: [{ id: 1, name: 'AutoMoney' }, { id: 2, name: 'Автодом24' }] },
]);
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
expect(store.loadDuplicateGroups).toHaveBeenCalled();
expect(w.text()).toContain('AutoMoney');
expect(w.text()).toContain('Автодом24');
expect(w.text()).toContain('совпал телефон');
});
it('«Объединить» зовёт merge с id группы и убирает группу из окна', async () => {
const store = baseStore();
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{ reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] },
]);
const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
await vm.mergeGroup(vm.dupes.groups[0]);
expect(merge).toHaveBeenCalledWith([1, 2], 'field', 'A');
expect(vm.dupes.groups.length).toBe(0);
});
it('имя объединённого по умолчанию — первого, передаётся в merge; можно сменить', async () => {
const store = baseStore();
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{ reason: 'r', competitors: [{ id: 1, name: 'Сибиряк' }, { id: 2, name: 'Нанжуль' }] },
]);
const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
expect(vm.dupes.groups[0].chosenName).toBe('Сибиряк');
vm.dupes.groups[0].chosenName = 'Нанжуль';
await vm.mergeGroup(vm.dupes.groups[0]);
expect(merge).toHaveBeenCalledWith([1, 2], 'field', 'Нанжуль');
});
it('группа из 3+: объединяем ПОДМНОЖЕСТВО (2 из 3)', async () => {
const store = baseStore();
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{ reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, { id: 3, name: 'C' }] },
]);
const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
// по умолчанию выбраны все три
expect(vm.dupes.groups[0].sel).toEqual([1, 2, 3]);
// снимаем C (id 3) → сливаем только A+B
vm.toggleDup(vm.dupes.groups[0], 3);
await vm.mergeGroup(vm.dupes.groups[0]);
expect(merge).toHaveBeenCalledWith([1, 2], 'field', 'A');
});
it('нельзя объединить меньше двух выбранных', async () => {
const store = baseStore();
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{ reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] },
]);
const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
vm.toggleDup(vm.dupes.groups[0], 2); // остаётся только один выбранный
await vm.mergeGroup(vm.dupes.groups[0]);
expect(merge).not.toHaveBeenCalled();
});
it('«Это разные» убирает группу без склейки', async () => {
const store = baseStore();
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{ reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] },
]);
const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
vm.dismissGroup(0);
expect(merge).not.toHaveBeenCalled();
expect(vm.dupes.groups.length).toBe(0);
});
it('на кнопке «Найти и объединить дубли» показывается число найденных дублей', async () => {
const store = baseStore();
// ≥2 конкурента в поле → кнопка видна и счётчик считается на загрузке
// eslint-disable-next-line @typescript-eslint/no-explicit-any
store.field = [
{ id: 1, name: 'A', box: 'field', relevance_pct: 100, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
{ id: 2, name: 'B', box: 'field', relevance_pct: 90, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
] as any;
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{ reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] },
{ reason: 'r2', competitors: [{ id: 3, name: 'C' }, { id: 4, name: 'D' }] },
]);
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
expect(vm.dupCount).toBe(2);
const btn = w.findAll('button').find((b) => b.text().includes('Найти и объединить дубли'));
expect(btn?.text()).toContain('2');
});
it('дублей нет → число на кнопке не показывается', async () => {
const store = baseStore();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
store.field = [
{ id: 1, name: 'A', box: 'field', relevance_pct: 100, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
{ id: 2, name: 'B', box: 'field', relevance_pct: 90, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
] as any;
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([]);
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
expect(vm.dupCount).toBe(0);
const btn = w.findAll('button').find((b) => b.text().includes('Найти и объединить дубли'));
expect(btn?.text()).not.toContain('(');
});
it('окно дублей — кросс-ящик: помечает предложение-дубль карточки поля', async () => {
const store = baseStore();
const spy = vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{
reason: 'совпал телефон',
competitors: [
{ id: 1, name: 'СМ-сити', box: 'field' },
{ id: 2, name: 'Южный берег', box: 'proposal' },
],
},
]);
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
expect(spy).toHaveBeenCalledWith('field', 'cross'); // зовём кросс-ящик поле↔предложения
expect(w.text()).toContain('Южный берег');
expect(w.text()).toContain('предложение'); // видно, что цепляем предложение
});
it('имя по умолчанию — у ВЫЖИВАЮЩЕЙ карточки (не первой), и видно «останется»', async () => {
const store = baseStore();
// survivor помечен бэкендом на ВТОРОЙ карточке (напр. полевая с проектами) — её имя должно
// стать именем объединённого по умолчанию, чтобы клиент не потерял её вслепую (баг «САПС»).
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([
{
reason: 'совпал телефон',
competitors: [
{ id: 1, name: 'Метрополис', box: 'proposal', survivor: false },
{ id: 2, name: 'СМ-сити', box: 'field', survivor: true },
],
},
]);
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
expect(vm.dupes.groups[0].chosenName).toBe('СМ-сити'); // имя выжившей, а не первой «Метрополис»
expect(w.text()).toContain('останется'); // клиенту видно, какая карточка останется
});
it('история слияний: видно «что во что слили», кнопка «Вернуть» зовёт restore', async () => {
const store = baseStore();
vi.spyOn(store, 'loadMergeEvents').mockResolvedValue([
{
id: 7,
survivor_id: 2,
survivor_name: 'СМ-сити',
reason: 'совпал телефон',
restored: false,
created_at: '2026-07-05T11:49:09+03:00',
absorbed: [{ id: 1, name: 'Метрополис', box: 'proposal' }],
},
]);
const restore = vi.spyOn(store, 'restoreMergeEvent').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openHistory();
await w.vm.$nextTick();
expect(store.loadMergeEvents).toHaveBeenCalled();
expect(w.text()).toContain('Метрополис'); // поглощённая
expect(w.text()).toContain('СМ-сити'); // выжившая
await vm.restoreEvent(vm.history.events[0]);
expect(restore).toHaveBeenCalledWith(7);
});
it('дублей нет → окно сообщает «дублей не найдено»', async () => {
const store = baseStore();
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([]);
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = w.vm as any;
await vm.openDupes();
await w.vm.$nextTick();
expect(w.text().toLowerCase()).toContain('дубл');
});
});