Files
portal/app/resources/js/api/autopodbor.ts
T
Дмитрий 9ba11e4bd0 feat(автоподбор шаг2): экран конкурента — «где нашли» ссылками + сортировка
Экран 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>
2026-06-30 14:47:55 +03:00

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