diff --git a/app/resources/js/components/billing/AutopodborServicesPanel.vue b/app/resources/js/components/billing/AutopodborServicesPanel.vue index dc79c788..db7ad563 100644 --- a/app/resources/js/components/billing/AutopodborServicesPanel.vue +++ b/app/resources/js/components/billing/AutopodborServicesPanel.vue @@ -29,6 +29,7 @@ onMounted(() => {
Сбор конкурентов
Подбор похожих конкурентов по вашим примерам и региону
+
при успешном подборе
{{ searchPrice }} ₽
@@ -37,6 +38,7 @@ onMounted(() => {
Сбор источников
Все источники одного конкурента (сайты и телефоны) для проектов
+
при успешном изучении
{{ studyPrice }} ₽
@@ -52,6 +54,13 @@ onMounted(() => { } .ap-row__name { min-width: 0; + flex: 1; +} +.ap-row__when { + flex-shrink: 0; + white-space: nowrap; + text-align: right; + min-width: 140px; } .ap-row__price { font-family: 'JetBrains Mono', ui-monospace, monospace; diff --git a/app/resources/js/layouts/AdminLayout.vue b/app/resources/js/layouts/AdminLayout.vue index a9b78cf6..ed539b0b 100644 --- a/app/resources/js/layouts/AdminLayout.vue +++ b/app/resources/js/layouts/AdminLayout.vue @@ -30,6 +30,7 @@ const navItems: NavItem[] = [ { title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' }, { title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' }, { title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' }, + { title: 'Тарифы «Конкурентного поля»', icon: 'mdi-bullseye-arrow', to: '/admin/autopodbor-pricing' }, { title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' }, { title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' }, { title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' }, diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index dfad8bdc..45afeeb0 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -270,6 +270,18 @@ const routes: RouteRecordRaw[] = [ devLabel: 'Admin Pricing Tiers', }, }, + { + path: '/admin/autopodbor-pricing', + name: 'admin-autopodbor-pricing', + component: () => import('../views/admin/AdminAutopodborPricingView.vue'), + meta: { + layout: 'admin', + title: 'Тарифы «Конкурентного поля»', + requiresAuth: true, + devIndex: 28, + devLabel: 'Admin Autopodbor Pricing', + }, + }, { path: '/admin/supplier-prices', name: 'admin-supplier-prices', diff --git a/app/resources/js/views/admin/AdminAutopodborPricingView.vue b/app/resources/js/views/admin/AdminAutopodborPricingView.vue new file mode 100644 index 00000000..d023a697 --- /dev/null +++ b/app/resources/js/views/admin/AdminAutopodborPricingView.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index 554f97db..652399c5 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -4,7 +4,6 @@ import { useAutopodborStore } from '../../stores/autopodborStore'; import FieldWorkspaceScreen from './screens/FieldWorkspaceScreen.vue'; import FieldCompetitorScreen from './screens/FieldCompetitorScreen.vue'; import FieldProposalsScreen from './screens/FieldProposalsScreen.vue'; -import FieldManualCompetitorScreen from './screens/FieldManualCompetitorScreen.vue'; import EntryScreen from './screens/EntryScreen.vue'; import AutoFormScreen from './screens/AutoFormScreen.vue'; import ManualFormScreen from './screens/ManualFormScreen.vue'; @@ -19,7 +18,6 @@ type ScreenName = | 'field' | 'fieldcompetitor' | 'field-proposals' - | 'field-manual-competitor' | 'entry' | 'autoform' | 'manualform' @@ -56,7 +54,6 @@ const screens: Partial> = { field: FieldWorkspaceScreen, fieldcompetitor: FieldCompetitorScreen, 'field-proposals': FieldProposalsScreen, - 'field-manual-competitor': FieldManualCompetitorScreen, entry: EntryScreen, autoform: AutoFormScreen, manualform: ManualFormScreen, diff --git a/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue deleted file mode 100644 index b4e9bb8d..00000000 --- a/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue +++ /dev/null @@ -1,211 +0,0 @@ - - - - - diff --git a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue index 4bd2e54a..7bff402a 100644 --- a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue @@ -1,7 +1,8 @@ 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('карточку конкурента'); + }); });