4387333118
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.
Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.
API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).
Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».
Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { defineStore } from 'pinia';
|
||
import { ref } from 'vue';
|
||
import {
|
||
fetchState,
|
||
fetchRun,
|
||
fetchCompetitor,
|
||
startSearch,
|
||
startStudy,
|
||
startResolve,
|
||
startManualStudy,
|
||
addManualSource,
|
||
createProjects,
|
||
fetchRunCompetitors,
|
||
fetchField,
|
||
fetchProposals,
|
||
setCompetitorBox,
|
||
setSourceBox,
|
||
updateCompetitor,
|
||
deleteCompetitor,
|
||
updateSource,
|
||
deleteSource,
|
||
createManualCompetitor,
|
||
toggleProjectActive as apiToggleProjectActive,
|
||
changeProjectSource as apiChangeProjectSource,
|
||
updateProjectSettings as apiUpdateProjectSettings,
|
||
type RunDto,
|
||
type ChangeSourceResult,
|
||
type CompetitorDto,
|
||
type SourceDto,
|
||
type Box,
|
||
type CompetitorPatch,
|
||
type SourcePatch,
|
||
type FieldCompetitorDto,
|
||
type FieldSourceDto,
|
||
} 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<FieldSourceDto[]>([]);
|
||
const runCompetitors = ref<CompetitorDto[]>([]);
|
||
const field = ref<FieldCompetitorDto[]>([]);
|
||
const proposals = ref<CompetitorDto[]>([]);
|
||
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;
|
||
} catch {
|
||
// Сетевая ошибка — enabled остаётся false, не роняем UI
|
||
} 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, project: null });
|
||
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;
|
||
}
|
||
}
|
||
|
||
async function loadRunCompetitors(runId: number): Promise<void> {
|
||
runCompetitors.value = await fetchRunCompetitors(runId);
|
||
}
|
||
|
||
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
|
||
|
||
async function loadField(): Promise<void> {
|
||
field.value = (await fetchField()) ?? [];
|
||
}
|
||
|
||
async function loadProposals(): Promise<void> {
|
||
proposals.value = (await fetchProposals()) ?? [];
|
||
}
|
||
|
||
/** Перенос конкурента предложение↔поле; уход из поля убирает карточку из списка. */
|
||
async function moveCompetitorToBox(id: number, box: Box): Promise<void> {
|
||
await setCompetitorBox(id, box);
|
||
if (box !== 'field') {
|
||
field.value = field.value.filter((c) => c.id !== id);
|
||
}
|
||
}
|
||
|
||
async function editCompetitor(id: number, patch: CompetitorPatch): Promise<void> {
|
||
const updated = await updateCompetitor(id, patch);
|
||
const idx = field.value.findIndex((c) => c.id === id);
|
||
if (idx !== -1) {
|
||
field.value[idx] = { ...field.value[idx], ...updated };
|
||
}
|
||
}
|
||
|
||
async function removeCompetitor(id: number): Promise<void> {
|
||
await deleteCompetitor(id);
|
||
field.value = field.value.filter((c) => c.id !== id);
|
||
}
|
||
|
||
async function addFieldCompetitor(p: {
|
||
name: string;
|
||
description?: string;
|
||
site_url?: string;
|
||
directory?: string;
|
||
is_federal?: boolean;
|
||
}): Promise<CompetitorDto> {
|
||
const created = await createManualCompetitor(p);
|
||
field.value.push({
|
||
...created,
|
||
counters: { sources: 0, projects_created: 0, projects_in_work: 0 },
|
||
sources: [],
|
||
});
|
||
return created;
|
||
}
|
||
|
||
/** Перенос источника предложение↔в работу внутри карточки конкурента. */
|
||
async function moveSourceToBox(competitorId: number, sourceId: number, box: Box): Promise<void> {
|
||
await setSourceBox(sourceId, box);
|
||
const comp = field.value.find((c) => c.id === competitorId);
|
||
if (comp && box !== 'field') {
|
||
comp.sources = comp.sources.filter((s) => s.id !== sourceId);
|
||
}
|
||
}
|
||
|
||
async function editSource(competitorId: number, sourceId: number, patch: SourcePatch): Promise<void> {
|
||
const updated = await updateSource(sourceId, patch);
|
||
const comp = field.value.find((c) => c.id === competitorId);
|
||
if (comp) {
|
||
const idx = comp.sources.findIndex((s) => s.id === sourceId);
|
||
if (idx !== -1) {
|
||
comp.sources[idx] = { ...comp.sources[idx], ...updated };
|
||
}
|
||
}
|
||
}
|
||
|
||
async function removeSource(competitorId: number, sourceId: number): Promise<void> {
|
||
await deleteSource(sourceId);
|
||
const comp = field.value.find((c) => c.id === competitorId);
|
||
if (comp) {
|
||
comp.sources = comp.sources.filter((s) => s.id !== sourceId);
|
||
}
|
||
}
|
||
|
||
/** Управление проектом источника через готовую ручку проектов (все гварды там). */
|
||
async function toggleProjectActive(projectId: number, active: boolean): Promise<void> {
|
||
await apiToggleProjectActive(projectId, active);
|
||
}
|
||
|
||
/** Смена источника проекта (change_source, §14.10) — через готовую ручку проектов. */
|
||
async function changeProjectSource(projectId: number, identifier: string): Promise<ChangeSourceResult> {
|
||
return await apiChangeProjectSource(projectId, identifier);
|
||
}
|
||
|
||
/** Настройки проекта (лимит/регионы/дни) — через готовую ручку проектов. */
|
||
async function updateProjectSettings(
|
||
projectId: number,
|
||
p: { daily_limit_target?: number; regions?: number[]; delivery_days_mask?: number },
|
||
): Promise<ChangeSourceResult> {
|
||
return await apiUpdateProjectSettings(projectId, p);
|
||
}
|
||
|
||
return {
|
||
// State
|
||
enabled,
|
||
prices,
|
||
runs,
|
||
currentRun,
|
||
competitor,
|
||
sources,
|
||
runCompetitors,
|
||
field,
|
||
proposals,
|
||
loading,
|
||
// Actions
|
||
loadState,
|
||
search,
|
||
study,
|
||
resolve,
|
||
manualStudy,
|
||
loadCompetitor,
|
||
addSource,
|
||
makeProjects,
|
||
pollRun,
|
||
stopPolling,
|
||
loadRunCompetitors,
|
||
// «Конкурентное поле»
|
||
loadField,
|
||
loadProposals,
|
||
moveCompetitorToBox,
|
||
editCompetitor,
|
||
removeCompetitor,
|
||
addFieldCompetitor,
|
||
moveSourceToBox,
|
||
editSource,
|
||
removeSource,
|
||
toggleProjectActive,
|
||
changeProjectSource,
|
||
updateProjectSettings,
|
||
};
|
||
});
|