diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index d97266ad..a1408925 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -49,6 +49,8 @@ export interface RunDto { queue_position: number | null; /** Текущий этап работы (пока прогон идёт): «этап stage из total: label». null — если ждёт/завершён. */ progress: RunProgress | null; + /** Итог прогона (для study: сколько новых источников и сколько ранее удалённых нашли снова). */ + result: RunResult | null; } /** Этап живого прогона для показа клиенту. */ @@ -58,6 +60,32 @@ export interface RunProgress { label: string; } +/** Итог сбора источников: новые + ранее удалённые (их id — для блока «вернуть?»). */ +export interface RunResult { + new_sources: number; + refound_deleted: number; + refound_deleted_ids: number[]; +} + +/** + * Честное сообщение клиенту по итогу сбора источников. Пусто (''), если итога нет (первый сбор + * покажет общий флеш). Формулировки без согласования числа с существительным — «источников — N». + */ +export function collectResultMessage(result: RunResult | null): string { + if (!result) return ''; + const { new_sources: added, refound_deleted: refound } = result; + if (added > 0 && refound > 0) { + return `Готово: новых источников — ${added}, ранее удалённых снова найдено — ${refound} (можно вернуть). Они в «Предложениях».`; + } + if (added > 0) { + return `Готово: новых источников — ${added}. Они в «Предложениях».`; + } + if (refound > 0) { + return `Новых источников нет. Ранее удалённых снова найдено — ${refound} (можно вернуть).`; + } + return 'Новых источников не нашлось — всё, что есть по этому конкуренту, вы уже видите.'; +} + export type Box = 'proposal' | 'field' | 'archived'; export type PhoneType = 'city' | 'mobile' | 'tollfree' | null; diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts index 4b0efbac..008af484 100644 --- a/app/resources/js/stores/autopodborStore.ts +++ b/app/resources/js/stores/autopodborStore.ts @@ -296,6 +296,15 @@ export const useAutopodborStore = defineStore('autopodbor', () => { sources.value = sources.value.filter((s) => s.id !== sourceId); } + /** + * «Вернуть» ранее удалённый источник (найден снова при повторном сборе): из архива → в предложения. + * Перезагружаем карточку, чтобы источник появился в «Предложениях», а из блока «вернуть» исчез. + */ + async function restoreSource(competitorId: number, sourceId: number): Promise { + await setSourceBox(sourceId, 'proposal'); + await loadCompetitor(competitorId); + } + /** Управление проектом источника через готовую ручку проектов (все гварды там). */ async function toggleProjectActive(projectId: number, active: boolean): Promise { await apiToggleProjectActive(projectId, active); @@ -366,6 +375,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => { moveSourceToBox, editSource, removeSource, + restoreSource, toggleProjectActive, bulkProjectsById, changeProjectSource, diff --git a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue index ee57b6c8..7943af25 100644 --- a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue @@ -1,11 +1,12 @@ + + + + diff --git a/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts b/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts index f605201c..f9648f94 100644 --- a/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts +++ b/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts @@ -168,13 +168,13 @@ describe('FieldCompetitorScreen', () => { expect(bulkSpy).toHaveBeenCalledWith('pause', expect.arrayContaining([100, 101])); }); - it('у изучённого конкурента нет кнопки «Собрать источники», показано «Источники собраны»', async () => { + it('у изучённого конкурента первичной кнопки «Собрать источники для меня» нет — вместо неё повтор', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10 })], { studied_at: '2026-06-30T00:00:00+00:00' }); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeFalsy(); - expect(w.text()).toContain('Источники собраны'); + expect(w.text()).toContain('Собрать источники ещё раз'); }); it('неизучённый конкурент показывает кнопку «Собрать источники для меня»', async () => { @@ -235,4 +235,32 @@ describe('FieldCompetitorScreen', () => { await new Promise((r) => setTimeout(r, 0)); expect(w.text()).toContain('тип не меняется'); }); + + it('у уже изученного конкурента кнопка — «Собрать источники ещё раз» (повтор разрешён)', async () => { + const store = useAutopodborStore(); + seed(store, [src({ id: 10, box: 'field' })], { studied_at: '2026-07-05T00:00:00Z' }); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Собрать источники ещё раз'); + expect(w.text()).not.toContain('Источники собраны'); + }); + + it('блок «ранее удалённые — вернуть?» показывает архивные источники из итога прогона', async () => { + const store = useAutopodborStore(); + seed(store, [ + src({ id: 10, box: 'field', identifier: 'okna.ru' }), + src({ id: 99, box: 'archived', identifier: 'staroe.ru' }), + ], { studied_at: '2026-07-05T00:00:00Z' }); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + // Итог последнего прогона: источник 99 — ранее удалённый, найден снова. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store.currentRun = { id: 5, result: { new_sources: 0, refound_deleted: 1, refound_deleted_ids: [99] } } as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (w.vm as any).switchTab('sugg'); + await w.vm.$nextTick(); + + expect(w.text().toLowerCase()).toContain('ранее удал'); + expect(w.text()).toContain('staroe.ru'); + }); }); diff --git a/app/tests/Frontend/RefoundDeletedSources.spec.ts b/app/tests/Frontend/RefoundDeletedSources.spec.ts new file mode 100644 index 00000000..557fc755 --- /dev/null +++ b/app/tests/Frontend/RefoundDeletedSources.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import RefoundDeletedSources from '../../resources/js/views/autopodbor/screens/RefoundDeletedSources.vue'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const src = (id: number, identifier: string): any => ({ id, identifier, signal_type: 'site', box: 'archived' }); + +describe('RefoundDeletedSources — блок «ранее удалённые, вернуть?»', () => { + it('показывает ранее удалённые источники и кнопки «Вернуть»', () => { + const w = mount(RefoundDeletedSources, { props: { sources: [src(7, 'staroe.ru'), src(9, '79001112233')] } }); + expect(w.text()).toContain('staroe.ru'); + expect(w.text()).toContain('79001112233'); + expect(w.text().toLowerCase()).toContain('ранее удал'); + expect(w.findAll('button').length).toBe(2); + }); + + it('клик «Вернуть» шлёт событие restore с id источника', async () => { + const w = mount(RefoundDeletedSources, { props: { sources: [src(7, 'staroe.ru')] } }); + await w.find('button').trigger('click'); + expect(w.emitted('restore')?.[0]).toEqual([7]); + }); + + it('пусто → блок не рисуется', () => { + const w = mount(RefoundDeletedSources, { props: { sources: [] } }); + expect(w.text().trim()).toBe(''); + }); +}); diff --git a/app/tests/Frontend/autopodborStore.spec.ts b/app/tests/Frontend/autopodborStore.spec.ts index 5164a7e5..158a0e60 100644 --- a/app/tests/Frontend/autopodborStore.spec.ts +++ b/app/tests/Frontend/autopodborStore.spec.ts @@ -173,4 +173,17 @@ describe('autopodborStore', () => { expect(api.deleteSource).toHaveBeenCalledWith(50); expect(s.sources.map((x) => x.id)).toEqual([51]); }); + + it('restoreSource возвращает ранее удалённый источник в предложения и перезагружает карточку', async () => { + (api.setSourceBox as ReturnType).mockResolvedValue({ id: 7, box: 'proposal' }); + (api.fetchCompetitor as ReturnType).mockResolvedValue({ + competitor: { id: 3, name: 'Окна' }, + sources: [{ id: 7, competitor_id: 3, signal_type: 'site', identifier: 'staroe.ru', box: 'proposal', project: null }], + }); + const s = useAutopodborStore(); + await s.restoreSource(3, 7); + expect(api.setSourceBox).toHaveBeenCalledWith(7, 'proposal'); + expect(s.sources).toHaveLength(1); + expect(s.sources[0].box).toBe('proposal'); + }); }); diff --git a/app/tests/Frontend/collectResultMessage.spec.ts b/app/tests/Frontend/collectResultMessage.spec.ts new file mode 100644 index 00000000..771019b0 --- /dev/null +++ b/app/tests/Frontend/collectResultMessage.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { collectResultMessage } from '../../resources/js/api/autopodbor'; + +describe('collectResultMessage — честный итог сбора источников', () => { + it('есть и новые, и ранее удалённые', () => { + const m = collectResultMessage({ new_sources: 2, refound_deleted: 1, refound_deleted_ids: [7] }); + expect(m).toContain('2'); + expect(m).toContain('1'); + expect(m.toLowerCase()).toContain('ранее удал'); + }); + + it('только новые', () => { + const m = collectResultMessage({ new_sources: 3, refound_deleted: 0, refound_deleted_ids: [] }); + expect(m).toContain('3'); + expect(m.toLowerCase()).toContain('предложен'); + expect(m.toLowerCase()).not.toContain('ранее удал'); + }); + + it('только ранее удалённые', () => { + const m = collectResultMessage({ new_sources: 0, refound_deleted: 2, refound_deleted_ids: [1, 2] }); + expect(m.toLowerCase()).toContain('новых источников нет'); + expect(m).toContain('2'); + }); + + it('ничего нового — честно говорим, что всё уже видно', () => { + const m = collectResultMessage({ new_sources: 0, refound_deleted: 0, refound_deleted_ids: [] }); + expect(m.toLowerCase()).toContain('не нашл'); + expect(m.toLowerCase()).toContain('уже'); + }); + + it('нет итога (null) — пустая строка (первый сбор через общий флеш)', () => { + expect(collectResultMessage(null)).toBe(''); + }); +});