9ba11e4bd0
Экран FieldCompetitorScreen: - «где нашли» теперь кликабельные ссылки на источники (where_found из DTO), адреса филиалов 2ГИС видны и кликабельны под номером; - сортировка источников: больше подтверждений — выше, подменные — вниз; - строка адреса офиса, счётчик подтверждений. DTO SourceDto расширен полями where_found/confirmations/office (опциональны — обратная совместимость: без них падаем на старое provenance_label). Histoire-стори с живыми данными КрасЛомбарда (рендер настоящего компонента). TDD: +2 теста, autopodbor-экраны 24/24. Бэкенд-провод where_found — отдельно (Plan C). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import axios from 'axios';
|
|
import { apiClient } from './client';
|
|
|
|
/**
|
|
* Адресные сообщения по коду ошибки автоподбора (бэкенд кладёт `{ error: 'code' }`).
|
|
* Общий `extractErrorMessage` читает `message`, поэтому для наших кодов нужен отдельный маппер —
|
|
* иначе клиент видит общий текст «Проверьте баланс» на ЛЮБУЮ ошибку.
|
|
*/
|
|
const AUTOPODBOR_ERROR_MESSAGES: Record<string, string> = {
|
|
balance_insufficient: 'Не хватает денег на балансе — пополните счёт, чтобы запустить.',
|
|
run_in_flight: 'Подбор уже идёт — дождитесь результата, повторно запускать не нужно.',
|
|
name_or_site_required: 'Укажите название или сайт конкурента.',
|
|
has_active_project: 'Сначала остановите проект на этом источнике.',
|
|
has_active_projects: 'Сначала остановите проекты этого конкурента.',
|
|
manage_via_project: 'Смена адреса/номера источника — через «Сменить источник» в проекте.',
|
|
};
|
|
|
|
export function autopodborErrorMessage(error: unknown, fallback: string): string {
|
|
if (axios.isAxiosError(error)) {
|
|
const code = (error.response?.data as { error?: string } | undefined)?.error;
|
|
if (code && AUTOPODBOR_ERROR_MESSAGES[code]) {
|
|
return AUTOPODBOR_ERROR_MESSAGES[code];
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// ——— 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;
|
|
competitor_id: number | null;
|
|
}
|
|
|
|
export type Box = 'proposal' | 'field';
|
|
export type PhoneType = 'city' | 'mobile' | 'tollfree' | null;
|
|
|
|
export interface CompetitorDto {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
is_federal: boolean;
|
|
relevance_pct: number | null;
|
|
origin: 'auto' | 'manual' | 'resolve';
|
|
box: Box;
|
|
site_url: string | null;
|
|
directory_urls: string[];
|
|
studied_at: string | null;
|
|
study_run_id: number | null;
|
|
search_run_id: number | null;
|
|
}
|
|
|
|
/** Одно место, где нашли источник (для кликабельного списка «где нашли»). */
|
|
export interface WhereFound {
|
|
label: string;
|
|
url: string | null;
|
|
}
|
|
|
|
export interface SourceDto {
|
|
id: number;
|
|
competitor_id: number;
|
|
signal_type: 'site' | 'call';
|
|
identifier: string;
|
|
phone_kind: 'real' | 'substitute' | null;
|
|
phone_type: PhoneType;
|
|
box: Box;
|
|
provenance_url: string | null;
|
|
provenance_label: string | null;
|
|
created_project_id: number | null;
|
|
existing_project_id?: number | null;
|
|
/** Полный список «где нашли» (места + ссылки); подтверждения = его длина. */
|
|
where_found?: WhereFound[];
|
|
confirmations?: number;
|
|
/** Адрес офиса/филиала, если источник привязан к конкретному адресу. */
|
|
office?: string | null;
|
|
}
|
|
|
|
/** Статус проекта, привязанного к источнику (для рабочего места «поле»). */
|
|
export interface SourceProjectDto {
|
|
id: number;
|
|
name: string;
|
|
signal_identifier: string | null;
|
|
is_active: boolean;
|
|
paused_at: string | null;
|
|
preflight_blocked_at: string | null;
|
|
daily_limit_target: number;
|
|
delivered_in_month: number;
|
|
delivery_days_mask: number;
|
|
regions: number[];
|
|
}
|
|
|
|
/** Ответ смены источника проекта (change_source, §14.10). */
|
|
export interface ChangeSourceResult {
|
|
applies_from?: string | null;
|
|
source_locked?: boolean;
|
|
source_change_message?: string | null;
|
|
}
|
|
|
|
export interface FieldSourceDto extends SourceDto {
|
|
project: SourceProjectDto | null;
|
|
}
|
|
|
|
export interface FieldCompetitorDto extends CompetitorDto {
|
|
counters: { sources: number; projects_created: number; projects_in_work: number };
|
|
sources: FieldSourceDto[];
|
|
}
|
|
|
|
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: FieldSourceDto[] }> {
|
|
const { data } = await apiClient.get<{ data: CompetitorDto; sources: FieldSourceDto[] }>(
|
|
`/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;
|
|
}
|
|
|
|
export async function fetchRunCompetitors(runId: number): Promise<CompetitorDto[]> {
|
|
const { data } = await apiClient.get<{ data: CompetitorDto[] }>(`/api/autopodbor/runs/${runId}/competitors`);
|
|
return data.data;
|
|
}
|
|
|
|
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
|
|
|
|
/** Конкуренты в поле с источниками в работе и счётчиками. */
|
|
export async function fetchField(): Promise<FieldCompetitorDto[]> {
|
|
const { data } = await apiClient.get<{ competitors: FieldCompetitorDto[] }>('/api/autopodbor/field');
|
|
return data.competitors;
|
|
}
|
|
|
|
/** Конкуренты в ящике «предложения» (сорт по похожести). */
|
|
export async function fetchProposals(): Promise<CompetitorDto[]> {
|
|
const { data } = await apiClient.get<{ data: CompetitorDto[] }>('/api/autopodbor/proposals');
|
|
return data.data;
|
|
}
|
|
|
|
export async function setCompetitorBox(id: number, box: Box): Promise<CompetitorDto> {
|
|
const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}/box`, { box });
|
|
return data.data;
|
|
}
|
|
|
|
export async function setSourceBox(id: number, box: Box): Promise<SourceDto> {
|
|
const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}/box`, { box });
|
|
return data.data;
|
|
}
|
|
|
|
export interface CompetitorPatch {
|
|
name?: string;
|
|
description?: string | null;
|
|
is_federal?: boolean;
|
|
relevance_pct?: number | null;
|
|
site_url?: string | null;
|
|
directory_urls?: string[];
|
|
box?: Box;
|
|
}
|
|
|
|
export async function updateCompetitor(id: number, patch: CompetitorPatch): Promise<CompetitorDto> {
|
|
const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}`, patch);
|
|
return data.data;
|
|
}
|
|
|
|
export async function deleteCompetitor(id: number): Promise<void> {
|
|
await apiClient.delete(`/api/autopodbor/competitors/${id}`);
|
|
}
|
|
|
|
export interface SourcePatch {
|
|
identifier?: string;
|
|
phone_kind?: 'real' | 'substitute' | null;
|
|
phone_type?: PhoneType;
|
|
provenance_url?: string | null;
|
|
provenance_label?: string | null;
|
|
box?: Box;
|
|
}
|
|
|
|
export async function updateSource(id: number, patch: SourcePatch): Promise<SourceDto> {
|
|
const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}`, patch);
|
|
return data.data;
|
|
}
|
|
|
|
export async function deleteSource(id: number): Promise<void> {
|
|
await apiClient.delete(`/api/autopodbor/sources/${id}`);
|
|
}
|
|
|
|
export async function createManualCompetitor(p: {
|
|
name: string;
|
|
description?: string;
|
|
site_url?: string;
|
|
directory?: string;
|
|
is_federal?: boolean;
|
|
}): Promise<CompetitorDto> {
|
|
const { data } = await apiClient.post<{ data: CompetitorDto }>('/api/autopodbor/competitors/manual', p);
|
|
return data.data;
|
|
}
|
|
|
|
/**
|
|
* Включить/выключить проект источника через ГОТОВУЮ ручку проектов —
|
|
* там все гварды (слепок 18:00 МСК, баланс, сделки, §14.9).
|
|
*/
|
|
export async function toggleProjectActive(projectId: number, active: boolean): Promise<void> {
|
|
await apiClient.patch(`/api/projects/${projectId}/toggle-active`, { is_active: active });
|
|
}
|
|
|
|
/**
|
|
* Сменить источник проекта (адрес/номер) через ГОТОВУЮ ручку проектов — это и есть
|
|
* change_source со всеми гвардами §14.10 (тип источника не меняется). Возвращает
|
|
* сообщение о сроках вступления в силу.
|
|
*/
|
|
export async function changeProjectSource(projectId: number, signalIdentifier: string): Promise<ChangeSourceResult> {
|
|
const { data } = await apiClient.patch<ChangeSourceResult>(`/api/projects/${projectId}`, {
|
|
signal_identifier: signalIdentifier,
|
|
});
|
|
return data ?? {};
|
|
}
|
|
|
|
/** Настройки проекта (лимит/регионы/дни) — через готовую ручку проектов (слепок §14.9). */
|
|
export async function updateProjectSettings(
|
|
projectId: number,
|
|
p: { daily_limit_target?: number; regions?: number[]; delivery_days_mask?: number },
|
|
): Promise<ChangeSourceResult> {
|
|
const { data } = await apiClient.patch<ChangeSourceResult>(`/api/projects/${projectId}`, p);
|
|
return data ?? {};
|
|
}
|