feat(автоподбор): фронт — api-клиент и Pinia store

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-28 14:58:51 +03:00
parent 45fea2a988
commit a984db6b12
3 changed files with 360 additions and 0 deletions
+117
View File
@@ -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<string, unknown>;
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<StateDto> {
const { data } = await apiClient.get<StateDto>('/api/autopodbor/state');
return data;
}
export async function fetchRun(id: number): Promise<RunDto> {
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<RunDto> {
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/search', p);
return data.data;
}
export async function startStudy(competitor_id: number): Promise<RunDto> {
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<RunDto> {
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<RunDto> {
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<SourceDto> {
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<Array<{ id: number; name: string }>> {
const { data } = await apiClient.post<{ data: Array<{ id: number; name: string }> }>('/api/autopodbor/projects', p);
return data.data;
}
+170
View File
@@ -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<string> = 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<RunDto[]>([]);
const currentRun = ref<RunDto | null>(null);
const competitor = ref<CompetitorDto | null>(null);
const sources = ref<SourceDto[]>([]);
const loading = ref(false);
// Internal poll handle — not exposed as reactive state.
let _pollTimeout: ReturnType<typeof setTimeout> | null = null;
// ——— Actions ———
async function loadState(): Promise<void> {
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<RunDto> {
const run = await startSearch(p);
currentRun.value = run;
return run;
}
async function study(competitorId: number): Promise<RunDto> {
const run = await startStudy(competitorId);
currentRun.value = run;
return run;
}
async function resolve(p: { name: string; region_code: number }): Promise<RunDto> {
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<RunDto> {
const run = await startManualStudy(p);
currentRun.value = run;
return run;
}
async function loadCompetitor(id: number): Promise<void> {
const result = await fetchCompetitor(id);
competitor.value = result.competitor;
sources.value = result.sources;
}
async function addSource(p: { competitor_id: number; raw: string }): Promise<SourceDto> {
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<Array<{ id: number; name: string }>> {
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<RunDto> {
stopPolling();
return new Promise<RunDto>((resolve) => {
async function tick(): Promise<void> {
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,
};
});
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
.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<typeof vi.fn>).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);
});
});