Files
portal/app/resources/js/api/autopodbor.ts
T
Дмитрий 2130cd74e9 feat(autopodbor): формы создания/правки конкурента через элементы - правка не теряет карточки
Бэк: хелпер CompetitorElements нормализует элементы и выводит легаси site_url/directory_urls
из ВСЕХ карточек. Ручки manual/update принимают elements.
Фронт: компонент CompetitorElementsEditor встроен в формы создания и правки на экранах
Поле и Предложения. Прежний баг - пересбор directory_urls из двух полей - закрыт.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:32:16 +03:00

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 ?? {};
}