diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts new file mode 100644 index 00000000..7bb8e090 --- /dev/null +++ b/app/resources/js/api/autopodbor.ts @@ -0,0 +1,117 @@ +import { apiClient } from './client'; + +// ——— DTOs ——— + +export type RunKind = 'search' | 'study' | 'resolve'; +export type RunStatus = 'queued' | 'running' | 'done' | 'empty' | 'failed'; + +export interface RunDto { + id: number; + kind: RunKind; + status: RunStatus; + region_code: number | null; + params: Record; + price_rub_charged: string | null; + error_code: string | null; + competitors_count: number; + sources_count: number; + started_at: string | null; + finished_at: string | null; + created_at: string | null; +} + +export interface CompetitorDto { + id: number; + name: string; + description: string | null; + is_federal: boolean; + relevance_pct: number | null; + origin: 'auto' | 'manual' | 'resolve'; + site_url: string | null; + directory_urls: string[]; + studied_at: string | null; + study_run_id: number | null; + search_run_id: number | null; +} + +export interface SourceDto { + id: number; + competitor_id: number; + signal_type: 'site' | 'call'; + identifier: string; + phone_kind: 'real' | 'substitute' | null; + provenance_url: string | null; + provenance_label: string | null; + created_project_id: number | null; + existing_project_id?: number | null; +} + +export interface StateDto { + enabled: boolean; + runs: RunDto[]; + prices: { search: string; study: string }; +} + +// ——— API functions ——— + +export async function fetchState(): Promise { + const { data } = await apiClient.get('/api/autopodbor/state'); + return data; +} + +export async function fetchRun(id: number): Promise { + const { data } = await apiClient.get<{ data: RunDto }>(`/api/autopodbor/runs/${id}`); + return data.data; +} + +export async function fetchCompetitor(id: number): Promise<{ competitor: CompetitorDto; sources: SourceDto[] }> { + const { data } = await apiClient.get<{ data: CompetitorDto; sources: SourceDto[] }>(`/api/autopodbor/competitors/${id}`); + return { competitor: data.data, sources: data.sources }; +} + +export async function startSearch(p: { + region_code: number; + examples: string[]; + about_self: string[]; + include_federal: boolean; +}): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/search', p); + return data.data; +} + +export async function startStudy(competitor_id: number): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/study', { competitor_id }); + return data.data; +} + +export async function startResolve(p: { name: string; region_code: number }): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/resolve', p); + return data.data; +} + +export async function startManualStudy(p: { + competitor_id?: number; + name?: string; + site_url?: string; + directory?: string; + region_code: number; +}): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/manual-study', p); + return data.data; +} + +export async function addManualSource(p: { competitor_id: number; raw: string }): Promise { + const { data } = await apiClient.post<{ data: SourceDto }>('/api/autopodbor/sources/manual', p); + return data.data; +} + +export async function createProjects(p: { + source_ids: number[]; + regions: number[]; + daily_limit_target: number; + delivery_days_mask: number; + launch: boolean; +}): Promise> { + const { data } = await apiClient.post<{ data: Array<{ id: number; name: string }> }>('/api/autopodbor/projects', p); + return data.data; +} diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts new file mode 100644 index 00000000..3e397e95 --- /dev/null +++ b/app/resources/js/stores/autopodborStore.ts @@ -0,0 +1,170 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { + fetchState, + fetchRun, + fetchCompetitor, + startSearch, + startStudy, + startResolve, + startManualStudy, + addManualSource, + createProjects, + type RunDto, + type CompetitorDto, + type SourceDto, +} from '../api/autopodbor'; + +/** Задержка между тиками опроса (вынесена для тестируемости). */ +export const POLL_MS = 2500; + +const TERMINAL: ReadonlySet = new Set(['done', 'empty', 'failed']); + +export const useAutopodborStore = defineStore('autopodbor', () => { + const enabled = ref(false); + const prices = ref<{ search: string; study: string }>({ search: '0', study: '0' }); + const runs = ref([]); + const currentRun = ref(null); + const competitor = ref(null); + const sources = ref([]); + const loading = ref(false); + + // Internal poll handle — not exposed as reactive state. + let _pollTimeout: ReturnType | null = null; + + // ——— Actions ——— + + async function loadState(): Promise { + loading.value = true; + try { + const state = await fetchState(); + enabled.value = state.enabled; + prices.value = state.prices; + runs.value = state.runs; + } finally { + loading.value = false; + } + } + + async function search(p: { + region_code: number; + examples: string[]; + about_self: string[]; + include_federal: boolean; + }): Promise { + const run = await startSearch(p); + currentRun.value = run; + return run; + } + + async function study(competitorId: number): Promise { + const run = await startStudy(competitorId); + currentRun.value = run; + return run; + } + + async function resolve(p: { name: string; region_code: number }): Promise { + const run = await startResolve(p); + currentRun.value = run; + return run; + } + + async function manualStudy(p: { + competitor_id?: number; + name?: string; + site_url?: string; + directory?: string; + region_code: number; + }): Promise { + const run = await startManualStudy(p); + currentRun.value = run; + return run; + } + + async function loadCompetitor(id: number): Promise { + const result = await fetchCompetitor(id); + competitor.value = result.competitor; + sources.value = result.sources; + } + + async function addSource(p: { competitor_id: number; raw: string }): Promise { + const source = await addManualSource(p); + sources.value.push(source); + return source; + } + + async function makeProjects(p: { + source_ids: number[]; + regions: number[]; + daily_limit_target: number; + delivery_days_mask: number; + launch: boolean; + }): Promise> { + return await createProjects(p); + } + + /** + * Опрашивает run каждые POLL_MS мс до терминального статуса. + * + * Реализация: первый запрос выполняется немедленно (без начального setTimeout), + * далее — рекурсивный setTimeout(POLL_MS). Это обеспечивает детерминированное + * поведение с vi.useFakeTimers() + vi.runAllTimersAsync(). + * + * Возвращает Promise, который резолвится в финальный RunDto. + * stopPolling() отменяет ожидающий тайм-аут (текущий tick уже не прерывается). + */ + function pollRun(id: number, onTick?: (run: RunDto) => void): Promise { + stopPolling(); + + return new Promise((resolve) => { + async function tick(): Promise { + const run = await fetchRun(id); + currentRun.value = run; + onTick?.(run); + + if (TERMINAL.has(run.status)) { + resolve(run); + return; + } + + // Schedule next tick only if not already cancelled by stopPolling(). + _pollTimeout = setTimeout(() => { + _pollTimeout = null; + tick(); + }, POLL_MS); + } + + // Start immediately — no leading delay. + tick(); + }); + } + + function stopPolling(): void { + if (_pollTimeout !== null) { + clearTimeout(_pollTimeout); + _pollTimeout = null; + } + } + + return { + // State + enabled, + prices, + runs, + currentRun, + competitor, + sources, + loading, + // Actions + loadState, + search, + study, + resolve, + manualStudy, + loadCompetitor, + addSource, + makeProjects, + pollRun, + stopPolling, + }; +}); diff --git a/app/tests/Frontend/autopodborStore.spec.ts b/app/tests/Frontend/autopodborStore.spec.ts new file mode 100644 index 00000000..a4721f0c --- /dev/null +++ b/app/tests/Frontend/autopodborStore.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; + +vi.mock('../../resources/js/api/autopodbor'); +import * as api from '../../resources/js/api/autopodbor'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +describe('autopodborStore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('loadState заполняет enabled/prices/runs', async () => { + (api.fetchState as ReturnType).mockResolvedValue({ + enabled: true, + prices: { search: '500', study: '300' }, + runs: [{ id: 1, kind: 'search', status: 'done' }], + }); + const s = useAutopodborStore(); + await s.loadState(); + expect(s.enabled).toBe(true); + expect(s.prices.search).toBe('500'); + expect(s.runs.length).toBe(1); + }); + + it('search кладёт currentRun', async () => { + (api.startSearch as ReturnType).mockResolvedValue({ id: 9, kind: 'search', status: 'queued' }); + const s = useAutopodborStore(); + await s.search({ region_code: 16, examples: ['okna.ru'], about_self: [], include_federal: true }); + expect(api.startSearch).toHaveBeenCalled(); + expect(s.currentRun?.id).toBe(9); + }); + + it('loadCompetitor кладёт competitor и sources', async () => { + (api.fetchCompetitor as ReturnType).mockResolvedValue({ + competitor: { id: 3, name: 'Окна' }, + sources: [{ id: 1, signal_type: 'site' }], + }); + const s = useAutopodborStore(); + await s.loadCompetitor(3); + expect(s.competitor?.id).toBe(3); + expect(s.sources.length).toBe(1); + }); + + it('pollRun опрашивает до терминального статуса', async () => { + vi.useFakeTimers(); + (api.fetchRun as ReturnType) + .mockResolvedValueOnce({ id: 5, kind: 'search', status: 'running' }) + .mockResolvedValueOnce({ id: 5, kind: 'search', status: 'done' }); + const s = useAutopodborStore(); + const p = s.pollRun(5); + // прокрутить таймеры и микрозадачи + await vi.runAllTimersAsync(); + const final = await p; + expect(final.status).toBe('done'); + expect(s.currentRun?.status).toBe('done'); + vi.useRealTimers(); + }); + + it('makeProjects возвращает созданные проекты', async () => { + (api.createProjects as ReturnType).mockResolvedValue([{ id: 1, name: 'Окна Комфорт' }]); + const s = useAutopodborStore(); + const res = await s.makeProjects({ + source_ids: [1], + regions: [16], + daily_limit_target: 20, + delivery_days_mask: 127, + launch: false, + }); + expect(res).toHaveLength(1); + }); +});