fix(конкурентное поле): 6 находок теста «тупого клиента» — ошибки, регион, дедуп, миграции

- адресные сообщения в окнах сбора/изучения (маппер autopodborErrorMessage)
- регион по умолчанию = пустой плейсхолдер «выберите регион»
- кнопка «Собрать источники» у изучённого конкурента → «Источники собраны»
- сквозной дедуп предложений между прогонами (без двойного списания, ретрай цел)
- убран захардкоженный admin_user_id с фронта (id ставит бэкенд)
- идемпотентный гард в 3 миграции автоподбора (migrate:fresh снова зелёный)
- заглушка Агента: +тип 8-800 (tollfree) для полноты эмуляции

Тесты: Pest автоподбор 82/82, Vitest 62/62, vite build зелёный.

эскейп: фиксируй (авторизовано владельцем)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 06:42:33 +03:00
parent 793b20a39c
commit 1b3683c6b1
14 changed files with 174 additions and 12 deletions
@@ -56,6 +56,22 @@ class RunAutopodborSearchJob implements ShouldQueue
$unique = $dedup->dedupCompetitors($res->competitors);
// Сквозной дедуп: убираем конкурентов, уже известных тенанту (в поле или предложениях
// из прошлых прогонов) — иначе повторный подбор плодит дубли карточек. Если после
// фильтра ничего нового не осталось — прогон пустой и НЕ списывается (как и обычное «пусто»).
// Исключаем конкурентов ЭТОГО же прогона (иначе ретрай упавшего прогона схлопнул бы
// собственные результаты в «пусто»). Фильтруем только чужие прогоны и ручных.
$existingKeys = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
->where(function ($q) use ($run) {
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
})
->pluck('dedup_key')
->all();
$unique = array_values(array_filter(
$unique,
fn (array $c) => ! in_array($c['dedup_key'], $existingKeys, true),
));
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
@@ -29,6 +29,7 @@ final class FakeCompetitorAgent implements CompetitorAgent
['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'phone_type' => 'city', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'],
['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'phone_type' => 'mobile', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'],
['signal_type' => 'call', 'identifier' => '88002001122', 'phone_kind' => 'real', 'phone_type' => 'tollfree', 'provenance_url' => 'https://okna-komfort-kzn.ru/contacts', 'provenance_label' => 'бесплатная линия 8-800 на сайте'],
]);
}
@@ -8,6 +8,14 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// Idempotent: после migrate:fresh schema.sql создаёт эту таблицу первой (canon-sync v8.58).
// Без гарда Schema::create падает дублем — как и в остальных миграциях проекта
// (см. 2026_05_19_..._create_supplier_manual_sync_queue).
$exists = DB::selectOne("SELECT to_regclass('public.autopodbor_runs') AS r");
if ($exists !== null && $exists->r !== null) {
return;
}
Schema::create('autopodbor_runs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
@@ -7,6 +7,12 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// Idempotent: после migrate:fresh schema.sql создаёт эту таблицу первой (canon-sync v8.58).
$exists = DB::selectOne("SELECT to_regclass('public.autopodbor_competitors') AS r");
if ($exists !== null && $exists->r !== null) {
return;
}
Schema::create('autopodbor_competitors', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
@@ -7,6 +7,12 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// Idempotent: после migrate:fresh schema.sql создаёт эту таблицу первой (canon-sync v8.58).
$exists = DB::selectOne("SELECT to_regclass('public.autopodbor_sources') AS r");
if ($exists !== null && $exists->r !== null) {
return;
}
Schema::create('autopodbor_sources', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
+1 -1
View File
@@ -315,7 +315,7 @@ export async function listSystemSettings(): Promise<SystemSetting[]> {
export interface UpdateSystemSettingPayload {
value: string;
reason: string; // ≥30 chars
admin_user_id: number; // на prod удалится
admin_user_id?: number; // опц.: id админа проставляет бэкенд из сессии (saas_admin auth); клиент не диктует
}
export interface UpdateSystemSettingResponse {
+25
View File
@@ -1,5 +1,30 @@
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';
@@ -173,11 +173,12 @@ async function save(): Promise<void> {
saving.value = true;
try {
const reasonText = reason.value.trim();
// admin_user_id НЕ шлём с клиента — бэкенд проставляет id админа из сессии (audit-log).
if (searchPrice.value !== origSearch.value) {
await updateSystemSetting(SEARCH_KEY, { value: String(searchPrice.value), reason: reasonText, admin_user_id: 1 });
await updateSystemSetting(SEARCH_KEY, { value: String(searchPrice.value), reason: reasonText });
}
if (studyPrice.value !== origStudy.value) {
await updateSystemSetting(STUDY_KEY, { value: String(studyPrice.value), reason: reasonText, admin_user_id: 1 });
await updateSystemSetting(STUDY_KEY, { value: String(studyPrice.value), reason: reasonText });
}
successMessage.value = 'Тарифы сохранены. Изменения применяются ко всем клиентам.';
successToastOpen.value = true;
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import type { FieldSourceDto } from '../../../api/autopodbor';
import { autopodborErrorMessage, type FieldSourceDto } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
interface AutopodborNav {
@@ -181,9 +181,9 @@ async function runCollect() {
collect.open = false;
ctab.value = 'sugg';
flash('Готово: найдены источники — выберите нужные');
} catch {
} catch (e) {
collect.running = false;
flash('Не удалось собрать источники. Проверьте баланс.');
flash(autopodborErrorMessage(e, 'Не удалось собрать источники. Попробуйте ещё раз.'));
}
}
@@ -379,7 +379,8 @@ defineExpose({ ctab, selected, inWork, props: props, switchTab, toggle, openEdit
<p class="ld-sub">{{ subtitle() }}</p>
<div class="ld-acts">
<button class="ld-btn primary" :disabled="busy" @click="openCollect"> Собрать источники для меня</button>
<button v-if="!comp?.studied_at" class="ld-btn primary" :disabled="busy" @click="openCollect"> Собрать источники для меня</button>
<button v-else class="ld-btn primary" disabled title="Источники по этому конкуренту уже собраны — повторный сбор не нужен"> Источники собраны</button>
<button class="ld-btn ghost" :disabled="busy" @click="openAdd">+ Добавить источник вручную</button>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import type { FieldCompetitorDto } from '../../../api/autopodbor';
import { autopodborErrorMessage, type FieldCompetitorDto } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
interface AutopodborNav {
@@ -95,7 +95,7 @@ const collect = reactive({
open: false,
running: false,
niche: '',
regionCode: regions[0]?.code ?? null,
regionCode: null as number | null,
selfSite: '',
includeFederal: true,
examples: [
@@ -131,9 +131,9 @@ async function runCollect() {
collect.open = false;
flash('Готово: добавлены предложения');
nav.go('field-proposals');
} catch {
} catch (e) {
collect.running = false;
flash('Не удалось запустить подбор. Проверьте баланс.');
flash(autopodborErrorMessage(e, 'Не удалось запустить подбор. Попробуйте ещё раз.'));
}
}
@@ -340,7 +340,7 @@ defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, openColle
<div class="ld-fld"><label>Чем вы занимаетесь (направление поиска) <span class="ld-req">*</span></label>
<textarea v-model="collect.niche" rows="2" class="ld-in" placeholder="Коротко — что вы делаете и для кого"></textarea></div>
<div class="ld-fld"><label>Регион поиска <span class="ld-req">*</span></label>
<select v-model="collect.regionCode" class="ld-in"><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<select v-model="collect.regionCode" class="ld-in"><option :value="null" disabled>— выберите регион —</option><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<div class="ld-fld"><label>Ваш сайт</label><input v-model="collect.selfSite" class="ld-in" placeholder="primer.ru" /></div>
<div class="ld-fld"><label>Примеры ваших конкурентов <span class="ld-req">*</span> минимум 2</label>
<div v-for="(ex, i) in collect.examples" :key="i" class="ld-ex">
@@ -59,3 +59,29 @@ it('пустой результат: status=empty, без списания', fun
->and($run->fresh()->price_rub_charged)->toBeNull()
->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
});
it('повторный подбор не дублирует известных конкурентов и не списывает (сквозной дедуп)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement("SET app.current_tenant_id = ".$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '300', 'type' => 'decimal']);
SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']);
$mk = fn () => AutopodborRun::create([
'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued',
'region_code' => 16, 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true],
]);
$run1 = $mk();
runSearchJob($run1->id);
$afterFirst = AutopodborCompetitor::where('tenant_id', $tenant->id)->count();
expect($afterFirst)->toBeGreaterThan(0);
$run2 = $mk();
runSearchJob($run2->id);
// Заглушка отдаёт тот же набор → второй прогон не добавляет дублей и не списывает
expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->count())->toBe($afterFirst)
->and($run2->fresh()->status)->toBe('empty')
->and($run2->fresh()->price_rub_charged)->toBeNull()
->and((string) $tenant->fresh()->balance_rub)->toBe('99700.00');
});
@@ -169,6 +169,23 @@ describe('FieldCompetitorScreen', () => {
expect(toggleSpy).toHaveBeenCalledWith(101, false);
});
it('у изучённого конкурента нет кнопки «Собрать источники», показано «Источники собраны»', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: '2026-06-30T00:00:00+00:00' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeFalsy();
expect(w.text()).toContain('Источники собраны');
});
it('неизучённый конкурент показывает кнопку «Собрать источники для меня»', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: null });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeTruthy();
});
it('окно «Изменить источник» открывается с залоченным типом', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, signal_type: 'site', identifier: 'okna.ru' })]);
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { AxiosError } from 'axios';
import { autopodborErrorMessage } from '../../resources/js/api/autopodbor';
function axErr(status: number, body: unknown): AxiosError {
const e = new AxiosError('err');
// @ts-expect-error — минимальный мок ответа
e.response = { status, data: body, statusText: '', headers: {}, config: {} };
return e;
}
describe('autopodborErrorMessage — адресные сообщения по коду ответа', () => {
it('balance_insufficient → про деньги/пополнение', () => {
const m = autopodborErrorMessage(axErr(409, { error: 'balance_insufficient' }), 'fallback');
expect(m.toLowerCase()).toContain('баланс');
expect(m).not.toBe('fallback');
});
it('run_in_flight → «подбор уже идёт»', () => {
const m = autopodborErrorMessage(axErr(409, { error: 'run_in_flight' }), 'fallback');
expect(m.toLowerCase()).toContain('уже идёт');
});
it('name_or_site_required → про название/сайт', () => {
const m = autopodborErrorMessage(axErr(422, { error: 'name_or_site_required' }), 'fallback');
expect(m.toLowerCase()).toContain('назван');
});
it('неизвестный код → fallback', () => {
const m = autopodborErrorMessage(axErr(500, { error: 'boom' }), 'мой fallback');
expect(m).toBe('мой fallback');
});
it('не-axios ошибка → fallback', () => {
const m = autopodborErrorMessage(new Error('x'), 'мой fallback');
expect(m).toBe('мой fallback');
});
});
@@ -18,3 +18,20 @@ it('заглушка резолвится через контейнер и от
$resolve = $agent->resolveByName(new ResolveByNameRequest('Окна Комфорт', 16));
expect($resolve->candidates)->not->toBeEmpty();
});
it('заглушка отдаёт полный набор типов телефонов вкл. 8-800 (tollfree)', function () {
$agent = app(CompetitorAgent::class);
$study = $agent->studyCompetitor(new StudyCompetitorRequest(['name'=>'Окна Комфорт','site_url'=>'okna-komfort-kzn.ru'], 16));
$phoneTypes = collect($study->sources)
->where('signal_type', 'call')
->pluck('phone_type')
->unique()
->values()
->all();
// Тупой клиент должен увидеть все три типа номера: городской, мобильный, 8-800.
expect($phoneTypes)->toContain('city')
->and($phoneTypes)->toContain('mobile')
->and($phoneTypes)->toContain('tollfree');
});