Files
portal/app/resources/js/stores/autopodborStore.ts
T
Дмитрий 4387333118 feat(Конкурентное поле): рабочее место конкуренты→источники→проекты (поверх автоподбора)
Фича «Конкурентное поле» на 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>
2026-06-30 04:18:46 +03:00

309 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
});