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:
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user