-
-
{{ c.name }}
-
федеральный
-
добавлен вручную
-
{{ c.relevance_pct }}%
+
+
Предложений пока нет
+
Соберите конкурентов — найденные появятся здесь, и вы перенесёте нужных в поле.
+
+
+
+
+
+
+
+
+ {{ c.name }}
+
+
+ {{ c.is_federal ? 'федеральный' : 'региональный' }}
+ добавлен вручную
+ предложение
-
{{ c.description }}
-
{{ c.site_url }}
-
-
В поле
+
+
+ {{ c.relevance_pct }}%
+
+
похожесть
+
—
-
-
-
+
+ {{ c.description || '—' }}
+
+ Справочник: {{ dirLabel(c) }} не указан
+
+
+
-
Выбрано конкурентов: {{ selected.length }}
-
- Перенести выбранных в поле
-
+
Выбрано конкурентов: {{ selected.length }} — это черновик, сохраняется
+
+ Снять выбор
+ Перенести выбранных в поле →
+
+
+
+
+
+
+ Собрать конкурентов для меня
+ Лидерра соберёт список конкурентов. Он ляжет в «Предложения» — вы сами выберете, кого взять в поле.
+
+
Как заполнить, чтобы результат был точным
+
+ Опишите простыми словами, чем вы занимаетесь — от этого зависит, кого мы найдём.
+ Дайте 2–5 примеров конкурентов, которых точно знаете. Чем больше — тем точнее.
+ У каждого примера укажите сайт или ссылку на 2ГИС/Яндекс.Карты.
+ Укажите свой сайт — чтобы не предлагать вас самих.
+
+
+ Чем вы занимаетесь (направление поиска) *
+
+ Регион поиска *
+ {{ r.name }}
+ Ваш сайт
+
+ Включать федеральных конкурентов
+ Да Нет
+ 💳 Сбор конкурентов — {{ prices.search }} ₽ за подбор. Деньги спишутся только если что-то найдём ; пустой результат — бесплатно.
+
+
+
+ Идёт подбор…
+ Можно закрыть вкладку — мы сохраним результат. Деньги спишутся только при успехе.
+
+
+
+
+
+
+
+
Изменить карточку конкурента
+
Можно поправить перед переносом в поле.
+
Название *
+
Тип региональный федеральный
+
+
Чем занимается (описание)
+
Сайт
+
Ссылка на карточку в 2ГИС
+
Ссылка на карточку в Яндекс.Картах
+
Укажите хотя бы одно: сайт или справочник — иначе мы не сможем найти источники конкурента.
+
+
+
+
+
+
+
+
Удалить конкурента?
+
«{{ del.name }}» будет удалён из предложений (если это лишнее или ошибочное предложение).
+
+
+
+
+
+ {{ toast }}
+
diff --git a/app/tests/Frontend/AdminAutopodborPricingView.spec.ts b/app/tests/Frontend/AdminAutopodborPricingView.spec.ts
new file mode 100644
index 00000000..8e1e8158
--- /dev/null
+++ b/app/tests/Frontend/AdminAutopodborPricingView.spec.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+
+vi.mock('../../resources/js/api/admin');
+import AdminAutopodborPricingView from '../../resources/js/views/admin/AdminAutopodborPricingView.vue';
+import { listSystemSettings, updateSystemSetting, getPricingTiers } from '../../resources/js/api/admin';
+
+const vuetify = createVuetify();
+
+function settings(search = '300', study = '50') {
+ return [
+ { key: 'autopodbor_price_search_rub', value: search, type: 'decimal', description: null, updated_at: '', updated_by: null },
+ { key: 'autopodbor_price_study_rub', value: study, type: 'decimal', description: null, updated_at: '', updated_by: null },
+ { key: 'other_key', value: '1', type: 'int', description: null, updated_at: '', updated_by: null },
+ ];
+}
+function tiers() {
+ return {
+ active: [{ tier_no: 1, leads_in_tier: 100, price_per_lead_kopecks: 50000, effective_from: '2026-06-01' }],
+ scheduled: {},
+ };
+}
+
+function mountV() {
+ return mount(AdminAutopodborPricingView, { global: { plugins: [vuetify] } });
+}
+
+describe('AdminAutopodborPricingView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(listSystemSettings).mockResolvedValue(settings() as any);
+ vi.mocked(getPricingTiers).mockResolvedValue(tiers() as any);
+ vi.mocked(updateSystemSetting).mockResolvedValue({} as any);
+ });
+
+ it('грузит текущие тарифы доп.услуг из system-settings', async () => {
+ const w = mountV();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(listSystemSettings).toHaveBeenCalled();
+ expect((w.vm as any).searchPrice).toBe('300');
+ expect((w.vm as any).studyPrice).toBe('50');
+ });
+
+ it('показывает сетку лидов для справки', async () => {
+ const w = mountV();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(w.text()).toContain('Тариф на лиды');
+ expect(w.text()).toContain('500'); // 50000 коп = 500 ₽
+ });
+
+ it('сохранение изменённой цены зовёт updateSystemSetting с value и reason', async () => {
+ const w = mountV();
+ await new Promise((r) => setTimeout(r, 0));
+ (w.vm as any).searchPrice = '350';
+ await (w.vm as any).save();
+ expect(updateSystemSetting).toHaveBeenCalledWith(
+ 'autopodbor_price_search_rub',
+ expect.objectContaining({ value: '350' }),
+ );
+ expect(updateSystemSetting).not.toHaveBeenCalledWith('autopodbor_price_study_rub', expect.anything());
+ });
+
+ it('причина короче 30 символов блокирует сохранение', async () => {
+ const w = mountV();
+ await new Promise((r) => setTimeout(r, 0));
+ (w.vm as any).searchPrice = '350';
+ (w.vm as any).reason = 'мало';
+ await (w.vm as any).save();
+ expect(updateSystemSetting).not.toHaveBeenCalled();
+ expect((w.vm as any).errorMessage).toContain('30');
+ });
+
+ it('без изменений не зовёт сохранение', async () => {
+ const w = mountV();
+ await new Promise((r) => setTimeout(r, 0));
+ await (w.vm as any).save();
+ expect(updateSystemSetting).not.toHaveBeenCalled();
+ expect((w.vm as any).errorMessage).toBeTruthy();
+ });
+});
diff --git a/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts b/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts
deleted file mode 100644
index 24d979fe..00000000
--- a/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-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 FieldManualCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue';
-import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
-
-const vuetify = createVuetify();
-
-function makeNav() {
- return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field-manual-competitor') };
-}
-
-function mountM(nav: any) {
- return mount(FieldManualCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
-}
-
-describe('FieldManualCompetitorScreen', () => {
- beforeEach(() => {
- setActivePinia(createPinia());
- vi.clearAllMocks();
- });
-
- it('пустое имя показывает ошибку и не отправляет', async () => {
- const store = useAutopodborStore();
- const addSpy = vi.spyOn(store, 'addFieldCompetitor');
- const w = mountM(makeNav());
- const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле');
- await btn!.trigger('click');
- await new Promise((r) => setTimeout(r, 0));
- expect(addSpy).not.toHaveBeenCalled();
- expect(w.text()).toContain('Укажите название');
- });
-
- it('заполнение и отправка добавляют конкурента и открывают его карточку', async () => {
- const store = useAutopodborStore();
- vi.spyOn(store, 'addFieldCompetitor').mockResolvedValue({ id: 42, name: 'Ромашка' } as any);
- const nav = makeNav();
- const w = mountM(nav);
- await w.find('input').setValue('Ромашка');
- const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле');
- await btn!.trigger('click');
- await new Promise((r) => setTimeout(r, 0));
- expect(store.addFieldCompetitor).toHaveBeenCalled();
- expect(nav.ctx.competitorId).toBe(42);
- expect(nav.go).toHaveBeenCalledWith('fieldcompetitor');
- });
-});
diff --git a/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts
index 815b28bf..a41da91d 100644
--- a/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts
+++ b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts
@@ -18,13 +18,13 @@ function comp(over: Partial
= {}) {
return {
id: 1,
name: 'Окна',
- description: null,
+ description: 'Окна ПВХ под ключ',
is_federal: false,
relevance_pct: 80,
origin: 'auto',
box: 'proposal',
site_url: 'okna.ru',
- directory_urls: [],
+ directory_urls: ['https://2gis.ru/firm/1', 'https://yandex.ru/maps/1'],
studied_at: null,
study_run_id: null,
search_run_id: 5,
@@ -42,18 +42,23 @@ describe('FieldProposalsScreen', () => {
vi.clearAllMocks();
});
- it('грузит предложения и показывает их', async () => {
+ it('грузит предложения и показывает карточку-плитку с похожестью и Справочником', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
- store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any;
+ store.proposals = [comp({ id: 1, name: 'Окна Комфорт', relevance_pct: 80 })] as any;
});
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
expect(store.loadProposals).toHaveBeenCalled();
+ expect(w.find('.ld-card').exists()).toBe(true);
expect(w.text()).toContain('Окна Комфорт');
+ expect(w.text()).toContain('80');
+ expect(w.text()).toContain('Справочник');
+ expect(w.text()).toContain('2ГИС');
+ expect(w.text()).toContain('Яндекс.Карты');
});
- it('«В поле» по конкуренту зовёт moveCompetitorToBox(field)', async () => {
+ it('«В поле →» по конкуренту зовёт moveCompetitorToBox(field)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 7 })] as any;
@@ -61,7 +66,7 @@ describe('FieldProposalsScreen', () => {
const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
- const btn = w.find('.ld-comp__act button');
+ const btn = w.find('.ld-cfoot button');
await btn.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(moveSpy).toHaveBeenCalledWith(7, 'field');
@@ -76,4 +81,52 @@ describe('FieldProposalsScreen', () => {
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Предложений пока нет');
});
+
+ it('«Собрать конкурентов» открывает окно сбора с ценой 300 ₽ (не уходит на старую форму)', async () => {
+ const store = useAutopodborStore();
+ vi.spyOn(store, 'loadProposals').mockResolvedValue();
+ store.prices = { search: '300', study: '50' };
+ const nav = makeNav();
+ const w = mountP(nav);
+ await new Promise((r) => setTimeout(r, 0));
+ const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов'));
+ await btn!.trigger('click');
+ expect(w.find('.ld-ovl').exists()).toBe(true);
+ expect(w.text()).toContain('Сбор конкурентов');
+ expect(w.text()).toContain('300 ₽');
+ expect(nav.go).not.toHaveBeenCalledWith('autoform');
+ });
+
+ it('массово переносит выбранных в поле при ≥2', async () => {
+ const store = useAutopodborStore();
+ vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
+ store.proposals = [comp({ id: 1 }), comp({ id: 2 })] as any;
+ });
+ const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
+ const w = mountP(makeNav());
+ await new Promise((r) => setTimeout(r, 0));
+ const boxes = w.findAll('.ld-pick');
+ await boxes[0].trigger('change');
+ await boxes[1].trigger('change');
+ expect(w.find('.ld-bulkbar').exists()).toBe(true);
+ const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Перенести'));
+ await btn!.trigger('click');
+ await new Promise((r) => setTimeout(r, 0));
+ expect(moveSpy).toHaveBeenCalledWith(1, 'field');
+ expect(moveSpy).toHaveBeenCalledWith(2, 'field');
+ });
+
+ it('«Изменить» открывает окно правки карточки конкурента', async () => {
+ const store = useAutopodborStore();
+ vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
+ store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any;
+ });
+ const w = mountP(makeNav());
+ await new Promise((r) => setTimeout(r, 0));
+ const link = w.findAll('.ld-link').find((b) => b.text().includes('Изменить'));
+ await link!.trigger('click');
+ await new Promise((r) => setTimeout(r, 0));
+ expect(w.find('.ld-ovl').exists()).toBe(true);
+ expect(w.text()).toContain('карточку конкурента');
+ });
});