feat(автоподбор): фронт — api-клиент и Pinia store
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user