Files
portal/app/resources/js/api/autopodbor.ts
T

503 lines
21 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 data = error.response?.data as
| { error?: string; message?: string; errors?: Record<string, string[]> }
| undefined;
const code = data?.error;
if (code && AUTOPODBOR_ERROR_MESSAGES[code]) {
return AUTOPODBOR_ERROR_MESSAGES[code];
}
// Нет известного кода — показываем КОНКРЕТНУЮ причину от бэкенда (валидация/бизнес-правило),
// а не общую фразу. Напр. «У вас уже есть проект с этим источником: «…»» при дубле источника.
const fieldError = data?.errors ? Object.values(data.errors)[0]?.[0] : undefined;
if (fieldError) {
return fieldError;
}
if (data?.message) {
return data.message;
}
}
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;
/** Место в общей очереди (пока прогон ждёт запуска). null — если уже идёт/завершён. */
queue_position: number | null;
/** Текущий этап работы (пока прогон идёт): «этап stage из total: label». null — если ждёт/завершён. */
progress: RunProgress | null;
/** Итог прогона (для study: сколько новых источников и сколько ранее удалённых нашли снова). */
result: RunResult | null;
}
/** Этап живого прогона для показа клиенту. */
export interface RunProgress {
stage: number;
total: number;
label: string;
}
/** Итог сбора источников: новые + ранее удалённые (их id — для блока «вернуть?»). */
export interface RunResult {
new_sources: number;
refound_deleted: number;
refound_deleted_ids: number[];
}
/**
* Честное сообщение клиенту по итогу сбора источников. Пусто (''), если итога нет (первый сбор
* покажет общий флеш). Формулировки без согласования числа с существительным — «источников — N».
*/
export function collectResultMessage(result: RunResult | null): string {
if (!result) return '';
const { new_sources: added, refound_deleted: refound } = result;
if (added > 0 && refound > 0) {
return `Готово: новых источников — ${added}, ранее удалённых снова найдено — ${refound} (можно вернуть). Они в «Предложениях».`;
}
if (added > 0) {
return `Готово: новых источников — ${added}. Они в «Предложениях».`;
}
if (refound > 0) {
return `Новых источников нет. Ранее удалённых снова найдено — ${refound} (можно вернуть).`;
}
return 'Новых источников не нашлось — всё, что есть по этому конкуренту, вы уже видите.';
}
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' | 'unverified' | 'uncertain' | 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[]; activeRun: RunDto | null }> {
const { data } = await apiClient.get<{ data: CompetitorDto; sources: FieldSourceDto[]; active_run: RunDto | null }>(
`/api/autopodbor/competitors/${id}`,
);
return { competitor: data.data, sources: data.sources, activeRun: data.active_run ?? null };
}
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 interface StudyBatchOk {
batch_id: string;
queued: number;
}
/**
* Пакетный сбор источников по нескольким конкурентам разом (шаг 2 для выборки в «Поле»).
* Бэкенд ставит задачи в очередь и отвечает 202 `{ batch_id, queued }`. При нехватке баланса —
* 422 `{ message, topup_rub, max_affordable }` (компонент показывает яркий алерт баланса).
* CSRF — как у соседних POST: `apiClient` уже шлёт X-XSRF-TOKEN автоматически (withXSRFToken).
*/
export async function startStudyBatch(competitorIds: number[]): Promise<StudyBatchOk> {
const { data } = await apiClient.post<StudyBatchOk>('/api/autopodbor/study/batch', {
competitor_ids: competitorIds,
});
return 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;
force?: boolean; // клиент подтвердил дубль
absorb_source_id?: number; // источник-дубль у прежнего конкурента — убрать
}): 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; box?: 'field' | 'proposal'; site_url?: string | null; directory_urls?: string[]; survivor?: boolean }>;
}
// box — какой ящик чистим (поле/предложения). scope='cross' — кросс-ящик поле↔предложения:
// на экране поля показываем и предложения-дубли карточек поля (бэкенд оставляет группы с ≥1 полем).
export async function fetchDuplicateGroups(box: 'field' | 'proposal' = 'field', scope?: 'cross'): Promise<DuplicateGroupDto[]> {
const { data } = await apiClient.get<{ groups: DuplicateGroupDto[] }>('/api/autopodbor/field/duplicate-groups', {
params: { box, ...(scope ? { scope } : {}) },
});
return data.groups;
}
export async function mergeCompetitors(competitorIds: number[], name?: string | null): Promise<{ survivor_id: number }> {
const { data } = await apiClient.post<{ survivor_id: number }>('/api/autopodbor/field/merge', {
competitor_ids: competitorIds,
name: name ?? undefined,
});
return data;
}
// Журнал слияний: что во что слили + снимок поглощённых. Делает склейку прозрачной и обратимой.
export interface MergeEventDto {
id: number;
survivor_id: number | null;
survivor_name: string;
reason: string | null;
restored: boolean;
created_at: string | null;
absorbed: Array<{ id: number | null; name: string; box?: 'field' | 'proposal' | 'archived' | null }>;
}
export async function fetchMergeEvents(): Promise<MergeEventDto[]> {
const { data } = await apiClient.get<{ events: MergeEventDto[] }>('/api/autopodbor/field/merge-events');
return data.events;
}
export async function restoreMergeEvent(eventId: number): Promise<{ restored: Array<{ id: number; name: string }> }> {
const { data } = await apiClient.post<{ restored: Array<{ id: number; name: string }> }>(
`/api/autopodbor/field/merge-events/${eventId}/restore`,
);
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 dismissActualizeProposal(findId: number): Promise<void> {
await apiClient.post(`/api/autopodbor/competitors/${findId}/dismiss-actualize`);
}
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' | 'unverified' | 'uncertain' | 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 ?? {};
}