2130cd74e9
Бэк: хелпер CompetitorElements нормализует элементы и выводит легаси site_url/directory_urls из ВСЕХ карточек. Ручки manual/update принимают elements. Фронт: компонент CompetitorElementsEditor встроен в формы создания и правки на экранах Поле и Предложения. Прежний баг - пересбор directory_urls из двух полей - закрыт. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
import axios from 'axios';
|
|
import { apiClient } from './client';
|
|
|
|
/**
|
|
* Адресные сообщения по коду ошибки автоподбора (бэкенд кладёт `{ error: 'code' }`).
|
|
* Общий `extractErrorMessage` читает `message`, поэтому для наших кодов нужен отдельный маппер —
|
|
* иначе клиент видит общий текст «Проверьте баланс» на ЛЮБУЮ ошибку.
|
|
*/
|
|
const AUTOPODBOR_ERROR_MESSAGES: Record<string, string> = {
|
|
balance_insufficient: 'Не хватает денег на балансе — пополните счёт, чтобы запустить.',
|
|
requisites_required: 'Заполните реквизиты компании — без них проект не создать. Откройте раздел «Реквизиты» в настройках.',
|
|
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' | 'archived';
|
|
export type PhoneType = 'city' | 'mobile' | 'tollfree' | null;
|
|
|
|
/** Элемент конкурента: подкомпания/ЖК холдинга со своими сайтами и карточками справочников. */
|
|
export interface CompetitorElement {
|
|
name: string;
|
|
sites: string[];
|
|
cards: string[];
|
|
}
|
|
|
|
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[];
|
|
elements: CompetitorElement[];
|
|
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 interface LaunchBalance {
|
|
topup_rub: number;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface LaunchSummary {
|
|
launched: number;
|
|
deferred: number;
|
|
balance: LaunchBalance | null;
|
|
}
|
|
|
|
export interface CreateProjectsResult {
|
|
data: Array<{ id: number; name: string; is_active?: boolean }>;
|
|
launch: LaunchSummary | null;
|
|
}
|
|
|
|
export async function createProjects(p: {
|
|
source_ids: number[];
|
|
regions: number[];
|
|
daily_limit_target: number;
|
|
delivery_days_mask: number;
|
|
launch: boolean;
|
|
}): Promise<CreateProjectsResult> {
|
|
const { data } = await apiClient.post<CreateProjectsResult>('/api/autopodbor/projects', p);
|
|
// Бэкенд отдаёт { data:[...], launch:{...} }; поддерживаем старый формат (только data.[])
|
|
if (Array.isArray(data)) {
|
|
return { data: data as Array<{ id: number; name: string }>, launch: null };
|
|
}
|
|
return 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 interface DuplicateGroupDto {
|
|
reason: string;
|
|
competitors: Array<{ id: number; name: string; site_url: string | null; directory_urls: string[] }>;
|
|
}
|
|
|
|
export async function fetchDuplicateGroups(box: 'field' | 'proposal' = 'field'): Promise<DuplicateGroupDto[]> {
|
|
const { data } = await apiClient.get<{ groups: DuplicateGroupDto[] }>('/api/autopodbor/field/duplicate-groups', {
|
|
params: { box },
|
|
});
|
|
return data.groups;
|
|
}
|
|
|
|
export async function mergeCompetitors(competitorIds: number[]): Promise<{ survivor_id: number }> {
|
|
const { data } = await apiClient.post<{ survivor_id: number }>('/api/autopodbor/field/merge', {
|
|
competitor_ids: competitorIds,
|
|
});
|
|
return data;
|
|
}
|
|
|
|
/** Совпавшая фирма-в-поле для карточки актуализации (её текущие сайты/карточки — для показа ссылками). */
|
|
export interface ActualizeMatched {
|
|
id: number;
|
|
name: string;
|
|
sites: string[];
|
|
directory_urls: string[];
|
|
}
|
|
|
|
/** Карточка группы «на актуализацию»: находка + на кого похожа + что нового (delta_keys). */
|
|
export interface ActualizeCard extends CompetitorDto {
|
|
matched_id: number;
|
|
delta_keys: string[];
|
|
matched?: ActualizeMatched;
|
|
}
|
|
|
|
/** Предложения, разложенные по группам (спека §3): новые / на актуализацию / ранее удалённые. */
|
|
export interface ProposalGroups {
|
|
new: CompetitorDto[];
|
|
actualize: ActualizeCard[];
|
|
archived: CompetitorDto[];
|
|
}
|
|
|
|
/** Предложения по группам (новые / актуализация / ранее удалённые). Скрытые дубли бэкенд не отдаёт. */
|
|
export async function fetchProposalGroups(): Promise<ProposalGroups> {
|
|
const { data } = await apiClient.get<{ groups: ProposalGroups }>('/api/autopodbor/proposals');
|
|
return data.groups;
|
|
}
|
|
|
|
/** Убрать всю группу «ранее удалённые» одним махом (снова в архив). */
|
|
export async function dismissArchivedProposals(): Promise<void> {
|
|
await apiClient.post('/api/autopodbor/proposals/dismiss-archived');
|
|
}
|
|
|
|
/**
|
|
* Актуализация сайта фирмы поля (спека §6): `add` — добавить новый сайт-источник (старый работает),
|
|
* `replace` — заменить (старый в архив; при активном проекте бэкенд вернёт 409 manage_via_project).
|
|
* Возвращает id заведённого источника — им сразу открываем форму создания проекта.
|
|
*/
|
|
export async function actualizeCompetitor(
|
|
competitorId: number,
|
|
action: 'add' | 'replace',
|
|
newSite: string,
|
|
): Promise<{ source_id: number }> {
|
|
const { data } = await apiClient.post<{ source_id: number }>(
|
|
`/api/autopodbor/competitors/${competitorId}/actualize`,
|
|
{ action, new_site: newSite },
|
|
);
|
|
return 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[];
|
|
elements?: CompetitorElement[];
|
|
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;
|
|
relevance_pct?: number | null;
|
|
elements?: CompetitorElement[];
|
|
}): 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 ?? {};
|
|
}
|